diff --git a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js index 0dd6f4bbb13c16a03bab2c6f77e48972437de6a4..61517d1cd56b2e9129d66563fd279dbc8894c193 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js +++ b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js @@ -17,6 +17,7 @@ export const formatGraphQLProjects = (projects) => ...project, id: getIdFromGraphQLId(id), name: nameWithNamespace, + avatarLabel: nameWithNamespace, mergeRequestsAccessLevel: mergeRequestsAccessLevel.stringValue, issuesAccessLevel: issuesAccessLevel.stringValue, forkingAccessLevel: forkingAccessLevel.stringValue, diff --git a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue index a5706a982bc1473cd84179a1c1908eda83631c72..1d8759ac78e40379a2436e564a8fb88e81fa0594 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue @@ -1,17 +1,11 @@ <script> -import { GlTruncateText } from '@gitlab/ui'; -import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import ListItemDescription from '~/vue_shared/components/resource_lists/list_item_description.vue'; export default { name: 'ProjectListItemDescription', - i18n: { - showMore: __('Show more'), - showLess: __('Show less'), - }, - truncateTextToggleButtonProps: { class: '!gl-text-sm' }, components: { - GlTruncateText, + ListItemDescription, }, directives: { SafeHtml, @@ -31,19 +25,5 @@ export default { </script> <template> - <gl-truncate-text - v-if="showDescription" - :lines="2" - :mobile-lines="2" - :show-more-text="$options.i18n.showMore" - :show-less-text="$options.i18n.showLess" - :toggle-button-props="$options.truncateTextToggleButtonProps" - class="gl-mt-2 gl-max-w-88" - > - <div - v-safe-html="project.descriptionHtml" - class="md md-child-content-text-subtle gl-text-sm" - data-testid="project-description" - ></div> - </gl-truncate-text> + <list-item-description v-if="showDescription" :description-html="project.descriptionHtml" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index 4b03d5b6f14fc32fecbd4eafc4eba96a2e4385ff..bf634f7305ddb08c70da0de06e3dba4c6c33c48e 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -1,13 +1,5 @@ <script> -import { - GlAvatarLabeled, - GlIcon, - GlLink, - GlBadge, - GlTooltipDirective, - GlPopover, - GlSprintf, -} from '@gitlab/ui'; +import { GlIcon, GlBadge, GlTooltipDirective, GlPopover, GlSprintf } from '@gitlab/ui'; import uniqueId from 'lodash/uniqueId'; import { @@ -23,7 +15,6 @@ import { FEATURABLE_ENABLED } from '~/featurable/constants'; import { __, s__ } from '~/locale'; import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import { truncate } from '~/lib/utils/text_utility'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; import DeleteModal from '~/projects/components/shared/delete_modal.vue'; import { @@ -32,6 +23,8 @@ import { } from '~/vue_shared/components/resource_lists/constants'; import { deleteProject } from '~/rest_api'; import { createAlert } from '~/alert'; +import ListItem from '~/vue_shared/components/resource_lists/list_item.vue'; +import ListItemStat from '~/vue_shared/components/resource_lists/list_item_stat.vue'; const MAX_TOPICS_TO_SHOW = 3; const MAX_TOPIC_TITLE_LENGTH = 15; @@ -45,8 +38,6 @@ export default { topics: __('Topics'), topicsPopoverTargetText: __('+ %{count} more'), moreTopics: __('More topics'), - [TIMESTAMP_TYPE_CREATED_AT]: __('Created'), - [TIMESTAMP_TYPE_UPDATED_AT]: __('Updated'), project: __('Project'), deleteErrorMessage: s__( 'Projects|An error occurred deleting the project. Please refresh the page to try again.', @@ -54,13 +45,12 @@ export default { ciCatalogBadge: s__('CiCatalog|CI/CD Catalog project'), }, components: { - GlAvatarLabeled, GlIcon, - GlLink, GlBadge, GlPopover, GlSprintf, - TimeAgoTooltip, + ListItem, + ListItemStat, DeleteModal, ProjectListItemDescription, ProjectListItemActions, @@ -212,12 +202,6 @@ export default { hasActionDelete() { return this.project.availableActions?.includes(ACTION_DELETE); }, - timestampText() { - return this.$options.i18n[this.timestampType]; - }, - timestamp() { - return this.project[this.timestampType]; - }, }, methods: { topicPath(topic) { @@ -255,170 +239,129 @@ export default { </script> <template> - <li class="projects-list-item gl-border-b gl-flex gl-py-5"> - <div class="gl-grow md:gl-flex"> - <div class="gl-flex gl-grow gl-items-start"> - <div v-if="showProjectIcon" class="gl-mr-3 gl-flex gl-h-9 gl-shrink-0 gl-items-center"> - <gl-icon name="project" variant="subtle" /> - </div> - <gl-avatar-labeled - :entity-id="project.id" - :entity-name="project.name" - :label="project.name" - :label-link="project.webUrl" - :src="project.avatarUrl" - shape="rect" - :size="48" + <list-item + :resource="project" + :show-icon="showProjectIcon" + icon-name="project" + :timestamp-type="timestampType" + > + <template #avatar-meta> + <gl-icon + v-if="visibility" + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + variant="subtle" + /> + <gl-badge + v-if="project.isCatalogResource" + icon="catalog-checkmark" + variant="info" + data-testid="ci-catalog-badge" + :href="project.exploreCatalogPath" + >{{ $options.i18n.ciCatalogBadge }}</gl-badge + > + <gl-badge v-if="shouldShowAccessLevel" class="gl-block" data-testid="access-level-badge">{{ + accessLevelLabel + }}</gl-badge> + </template> + + <template #avatar-default> + <project-list-item-description :project="project" /> + <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> + <div + class="-gl-mx-2 -gl-my-2 gl-inline-flex gl-w-full gl-flex-wrap gl-items-center gl-text-base gl-font-normal" > - <template #meta> - <div class="gl-px-2"> - <div class="-gl-mx-2 gl-flex gl-flex-wrap gl-items-center"> - <div class="gl-px-2"> - <gl-icon - v-if="visibility" - v-gl-tooltip="visibilityTooltip" - :name="visibilityIcon" - variant="subtle" - /> - </div> - <div v-if="project.isCatalogResource" class="gl-px-2"> - <gl-badge - icon="catalog-checkmark" - variant="info" - data-testid="ci-catalog-badge" - :href="project.exploreCatalogPath" - >{{ $options.i18n.ciCatalogBadge }}</gl-badge - > - </div> - <div class="gl-px-2"> - <gl-badge - v-if="shouldShowAccessLevel" - class="gl-block" - data-testid="access-level-badge" - >{{ accessLevelLabel }}</gl-badge - > - </div> - </div> - </div> - </template> - <project-list-item-description :project="project" /> - <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics"> + <span class="gl-p-2 gl-text-sm gl-text-subtle">{{ $options.i18n.topics }}:</span> + <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> + </div> + <template v-if="popoverTopics.length"> <div - class="-gl-mx-2 -gl-my-2 gl-inline-flex gl-w-full gl-flex-wrap gl-items-center gl-text-base gl-font-normal" + :id="topicsPopoverTarget" + class="gl-p-2 gl-text-sm gl-text-subtle" + role="button" + tabindex="0" > - <span class="gl-p-2 gl-text-sm gl-text-subtle">{{ $options.i18n.topics }}:</span> - <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2"> - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> - {{ topicTitle(topic) }} - </gl-badge> - </div> - <template v-if="popoverTopics.length"> - <div - :id="topicsPopoverTarget" - class="gl-p-2 gl-text-sm gl-text-subtle" - role="button" - tabindex="0" - > - <gl-sprintf :message="$options.i18n.topicsPopoverTargetText"> - <template #count>{{ popoverTopics.length }}</template> - </gl-sprintf> - </div> - <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics"> - <div class="-gl-mx-2 -gl-my-2 gl-text-base gl-font-normal"> - <div v-for="topic in popoverTopics" :key="topic" class="gl-inline-block gl-p-2"> - <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> - {{ topicTitle(topic) }} - </gl-badge> - </div> - </div> - </gl-popover> - </template> + <gl-sprintf :message="$options.i18n.topicsPopoverTargetText"> + <template #count>{{ popoverTopics.length }}</template> + </gl-sprintf> </div> - </div> - </gl-avatar-labeled> - </div> - <div - class="gl-mt-3 gl-shrink-0 gl-flex-col gl-items-end md:gl-mt-0 md:gl-flex md:gl-pl-0" - :class="showProjectIcon ? 'gl-pl-12' : 'gl-pl-10'" - > - <div class="gl-flex gl-items-center gl-gap-x-3 md:gl-h-9"> - <project-list-item-inactive-badge :project="project" /> - <gl-link - v-gl-tooltip="$options.i18n.stars" - :href="starsHref" - :aria-label="$options.i18n.stars" - class="gl-text-subtle" - data-testid="stars-btn" - > - <gl-icon name="star-o" /> - <span>{{ starCount }}</span> - </gl-link> - <gl-link - v-if="isForkingEnabled" - v-gl-tooltip="$options.i18n.forks" - :href="forksHref" - :aria-label="$options.i18n.forks" - class="gl-text-subtle" - data-testid="forks-btn" - > - <gl-icon name="fork" /> - <span>{{ forksCount }}</span> - </gl-link> - <gl-link - v-if="isMergeRequestsEnabled" - v-gl-tooltip="$options.i18n.mergeRequests" - :href="mergeRequestsHref" - :aria-label="$options.i18n.mergeRequests" - class="gl-text-subtle" - data-testid="mrs-btn" - > - <gl-icon name="merge-request" /> - <span>{{ openMergeRequestsCount }}</span> - </gl-link> - <gl-link - v-if="isIssuesEnabled" - v-gl-tooltip="$options.i18n.issues" - :href="issuesHref" - :aria-label="$options.i18n.issues" - class="gl-text-subtle" - data-testid="issues-btn" - > - <gl-icon name="issues" /> - <span>{{ openIssuesCount }}</span> - </gl-link> - </div> - <div - v-if="timestamp" - class="gl-mt-3 gl-whitespace-nowrap gl-text-sm gl-text-subtle md:-gl-mt-2" - > - <span>{{ timestampText }}</span> - <time-ago-tooltip :time="timestamp" /> + <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics"> + <div class="-gl-mx-2 -gl-my-2 gl-text-base gl-font-normal"> + <div v-for="topic in popoverTopics" :key="topic" class="gl-inline-block gl-p-2"> + <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)"> + {{ topicTitle(topic) }} + </gl-badge> + </div> + </div> + </gl-popover> + </template> </div> </div> - </div> - <div v-if="hasActions" class="gl-ml-3 gl-flex gl-h-9 gl-items-center"> + </template> + + <template #stats> + <project-list-item-inactive-badge :project="project" /> + <list-item-stat + :href="starsHref" + :tooltip-text="$options.i18n.stars" + icon-name="star-o" + :stat="starCount" + data-testid="stars-btn" + /> + <list-item-stat + v-if="isForkingEnabled" + :href="forksHref" + :tooltip-text="$options.i18n.forks" + icon-name="fork" + :stat="forksCount" + data-testid="forks-btn" + /> + <list-item-stat + v-if="isMergeRequestsEnabled" + :href="mergeRequestsHref" + :tooltip-text="$options.i18n.mergeRequests" + icon-name="merge-request" + :stat="openMergeRequestsCount" + data-testid="mrs-btn" + /> + <list-item-stat + v-if="isIssuesEnabled" + :href="issuesHref" + :tooltip-text="$options.i18n.issues" + icon-name="issues" + :stat="openIssuesCount" + data-testid="issues-btn" + /> + </template> + + <template v-if="hasActions" #actions> <project-list-item-actions :project="project" @refetch="$emit('refetch')" @delete="onActionDelete" /> - </div> + </template> - <delete-modal - v-if="hasActionDelete" - v-model="isDeleteModalVisible" - :confirm-phrase="project.name" - :is-fork="project.isForked" - :confirm-loading="isDeleteLoading" - :merge-requests-count="openMergeRequestsCount" - :issues-count="openIssuesCount" - :forks-count="forksCount" - :stars-count="starCount" - @primary="onDeleteModalPrimary" - > - <template #modal-footer - ><project-list-item-delayed-deletion-modal-footer :project="project" - /></template> - </delete-modal> - </li> + <template #footer> + <delete-modal + v-if="hasActionDelete" + v-model="isDeleteModalVisible" + :confirm-phrase="project.name" + :is-fork="project.isForked" + :confirm-loading="isDeleteLoading" + :merge-requests-count="openMergeRequestsCount" + :issues-count="openIssuesCount" + :forks-count="forksCount" + :stars-count="starCount" + @primary="onDeleteModalPrimary" + > + <template #modal-footer + ><project-list-item-delayed-deletion-modal-footer :project="project" + /></template> + </delete-modal> + </template> + </list-item> </template> diff --git a/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue b/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue index ebe372e6696da18b4e7c67befa878cc9252faf4e..4fb3164fa7a0474b42e8c112e9c6060335406542 100644 --- a/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue @@ -1,5 +1,5 @@ <script> -import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui'; +import { GlAvatarLabeled, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; @@ -9,21 +9,19 @@ import { TIMESTAMP_TYPE_CREATED_AT, TIMESTAMP_TYPE_UPDATED_AT, } from '~/vue_shared/components/resource_lists/constants'; +import ListItemDescription from './list_item_description.vue'; export default { i18n: { - showMore: __('Show more'), - showLess: __('Show less'), [TIMESTAMP_TYPE_CREATED_AT]: __('Created'), [TIMESTAMP_TYPE_UPDATED_AT]: __('Updated'), }, - truncateTextToggleButtonProps: { class: '!gl-text-sm' }, components: { GlAvatarLabeled, GlIcon, - GlTruncateText, ListActions, TimeAgoTooltip, + ListItemDescription, }, directives: { GlTooltip: GlTooltipDirective, @@ -34,7 +32,7 @@ export default { type: Object, required: true, validator(resource) { - const requiredKeys = ['id', 'avatarUrl', 'avatarLabel', 'webUrl', 'availableActions']; + const requiredKeys = ['id', 'avatarUrl', 'avatarLabel', 'webUrl']; return requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(resource, key)); }, @@ -76,7 +74,10 @@ export default { return this.$options.i18n[this.timestampType]; }, hasActions() { - return Object.keys(this.actions).length && this.resource.availableActions?.length; + return ( + this.$scopedSlots.actions || + (Object.keys(this.actions).length && this.resource.availableActions?.length) + ); }, }, }; @@ -105,21 +106,12 @@ export default { </div> </div> </template> - <gl-truncate-text - v-if="resource.descriptionHtml" - :lines="2" - :mobile-lines="2" - :show-more-text="$options.i18n.showMore" - :show-less-text="$options.i18n.showLess" - :toggle-button-props="$options.truncateTextToggleButtonProps" - class="gl-mt-2 gl-max-w-88" - > - <div - v-safe-html="resource.descriptionHtml" - class="md md-child-content-text-subtle gl-text-sm" - data-testid="description" - ></div> - </gl-truncate-text> + <slot name="avatar-default"> + <list-item-description + v-if="resource.descriptionHtml" + :description-html="resource.descriptionHtml" + /> + </slot> </gl-avatar-labeled> </div> <div @@ -138,12 +130,10 @@ export default { </div> </div> </div> - <div class="-gl-mt-3 gl-ml-3 gl-flex gl-items-center"> - <list-actions - v-if="hasActions" - :actions="actions" - :available-actions="resource.availableActions" - /> + <div v-if="hasActions" class="-gl-mt-3 gl-ml-3 gl-flex gl-items-center"> + <slot name="actions"> + <list-actions :actions="actions" :available-actions="resource.availableActions" /> + </slot> </div> <slot name="footer"></slot> diff --git a/app/assets/javascripts/vue_shared/components/resource_lists/list_item_description.vue b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_description.vue new file mode 100644 index 0000000000000000000000000000000000000000..be332a5b539f2e83b4fbed6b7d0706b17b3f8904 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_description.vue @@ -0,0 +1,43 @@ +<script> +import { GlTruncateText } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + i18n: { + showMore: __('Show more'), + showLess: __('Show less'), + }, + truncateTextToggleButtonProps: { class: '!gl-text-sm' }, + components: { + GlTruncateText, + }, + directives: { + SafeHtml, + }, + props: { + descriptionHtml: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-truncate-text + :lines="2" + :mobile-lines="2" + :show-more-text="$options.i18n.showMore" + :show-less-text="$options.i18n.showLess" + :toggle-button-props="$options.truncateTextToggleButtonProps" + class="gl-mt-2 gl-max-w-88" + > + <div + v-safe-html="descriptionHtml" + class="md md-child-content-text-subtle gl-text-sm" + data-testid="description" + ></div> + </gl-truncate-text> +</template> diff --git a/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue index 4cd82dd13b6860641d363f1b324743310efc66ba..ea6197916372cbc07509e50160ace6a536a5b1f5 100644 --- a/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue +++ b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective, GlLink } from '@gitlab/ui'; export default { components: { GlIcon }, @@ -20,17 +20,29 @@ export default { type: [String, Number], required: true, }, + href: { + type: String, + required: false, + default: null, + }, + }, + computed: { + component() { + return this.href ? GlLink : 'div'; + }, }, }; </script> <template> - <div + <component + :is="component" v-gl-tooltip="tooltipText" :aria-label="tooltipText" + :href="href" class="gl-flex gl-items-center gl-gap-x-2 gl-text-subtle" > <gl-icon :name="iconName" /> <span class="gl-leading-1">{{ stat }}</span> - </div> + </component> </template> diff --git a/app/assets/stylesheets/page_bundles/projects.scss b/app/assets/stylesheets/page_bundles/projects.scss index 9f210376402a5ca21656952f66f39b24bd239551..131b30042856716c914da6fee0af79755c6da469 100644 --- a/app/assets/stylesheets/page_bundles/projects.scss +++ b/app/assets/stylesheets/page_bundles/projects.scss @@ -553,20 +553,6 @@ } } -.projects-list-item { - .description { - max-height: $gl-spacing-scale-8; - - p { - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - text-overflow: ellipsis; - /* stylelint-disable-next-line value-no-vendor-prefix */ - display: -webkit-box; - } - } -} - .projects-list .description p { @apply gl-line-clamp-2 gl-whitespace-normal; margin-bottom: 0; diff --git a/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js index bbd65a199e836951d127aac589c4651524219d11..b8ab120284739c99df5144d66577378b9e1d96af 100644 --- a/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js +++ b/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js @@ -12,7 +12,8 @@ import DeleteModal from '~/projects/components/shared/delete_modal.vue'; describe('ProjectsListItemEE', () => { let wrapper; - const [project] = convertObjectPropsToCamelCase(projects, { deep: true }); + const [mockProject] = convertObjectPropsToCamelCase(projects, { deep: true }); + const project = { ...mockProject, avatarLabel: mockProject.nameWithNamespace, isForked: false }; const defaultProps = { project }; diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js index dad6916b742c4e941d475211cd72f89a750c29d4..1c19fbb57059db39387f67ff3faea4996a31e4fc 100644 --- a/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js +++ b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js @@ -76,7 +76,7 @@ describe('GroupsListItem', () => { it('renders subgroup count', () => { createComponent(); - expect(wrapper.findByTestId('subgroups-count').props()).toEqual({ + expect(wrapper.findByTestId('subgroups-count').props()).toMatchObject({ tooltipText: 'Subgroups', iconName: 'subgroup', stat: group.descendantGroupsCount.toString(), @@ -86,7 +86,7 @@ describe('GroupsListItem', () => { it('renders projects count', () => { createComponent(); - expect(wrapper.findByTestId('projects-count').props()).toEqual({ + expect(wrapper.findByTestId('projects-count').props()).toMatchObject({ tooltipText: 'Projects', iconName: 'project', stat: group.projectsCount.toString(), @@ -96,7 +96,7 @@ describe('GroupsListItem', () => { it('renders members count', () => { createComponent(); - expect(wrapper.findByTestId('members-count').props()).toEqual({ + expect(wrapper.findByTestId('members-count').props()).toMatchObject({ tooltipText: 'Direct members', iconName: 'users', stat: group.groupMembersCount.toString(), diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js index 1960e29a5e21d846c20cbc0077cf19af2949971d..faf7fcf8658958c973134a57b90acaf9fe2e818f 100644 --- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js +++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js @@ -47,16 +47,20 @@ jest.mock('~/api/projects_api'); describe('ProjectsListItem', () => { let wrapper; - const [{ permissions, ...project }] = convertObjectPropsToCamelCase(projects, { deep: true }); + const [{ permissions, ...mockProject }] = convertObjectPropsToCamelCase(projects, { deep: true }); - const defaultPropsData = { - project: { - ...project, - accessLevel: { - integerValue: permissions.projectAccess.accessLevel, - }, - avatarUrl: 'avatar.jpg', + const project = { + ...mockProject, + accessLevel: { + integerValue: permissions.projectAccess.accessLevel, }, + avatarUrl: 'avatar.jpg', + avatarLabel: mockProject.nameWithNamespace, + isForked: false, + }; + + const defaultPropsData = { + project, }; const createComponent = ({ propsData = {} } = {}) => { @@ -69,9 +73,9 @@ describe('ProjectsListItem', () => { }; const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); - const findMergeRequestsLink = () => wrapper.findByTestId('mrs-btn'); - const findIssuesLink = () => wrapper.findByTestId('issues-btn'); - const findForksLink = () => wrapper.findByTestId('forks-btn'); + const findMergeRequestsStat = () => wrapper.findByTestId('mrs-btn'); + const findIssuesStat = () => wrapper.findByTestId('issues-btn'); + const findForksStat = () => wrapper.findByTestId('forks-btn'); const findProjectTopics = () => wrapper.findByTestId('project-topics'); const findPopover = () => findProjectTopics().findComponent(GlPopover); const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon); @@ -97,13 +101,13 @@ describe('ProjectsListItem', () => { const avatarLabeled = findAvatarLabeled(); expect(avatarLabeled.props()).toMatchObject({ - label: project.name, + label: project.nameWithNamespace, labelLink: project.webUrl, }); expect(avatarLabeled.attributes()).toMatchObject({ 'entity-id': project.id.toString(), - 'entity-name': project.name, + 'entity-name': project.nameWithNamespace, src: defaultPropsData.project.avatarUrl, shape: 'rect', }); @@ -143,7 +147,7 @@ describe('ProjectsListItem', () => { describe('when access level is not available', () => { beforeEach(() => { createComponent({ - propsData: { project }, + propsData: { project: { ...project, accessLevel: null } }, }); }); @@ -175,13 +179,12 @@ describe('ProjectsListItem', () => { it('renders stars count', () => { createComponent(); - const starsLink = wrapper.findByTestId('stars-btn'); - const tooltip = getBinding(starsLink.element, 'gl-tooltip'); - - expect(tooltip.value).toBe(ProjectsListItem.i18n.stars); - expect(starsLink.attributes('href')).toBe(`${project.webUrl}/-/starrers`); - expect(starsLink.text()).toBe(project.starCount.toString()); - expect(starsLink.findComponent(GlIcon).props('name')).toBe('star-o'); + expect(wrapper.findByTestId('stars-btn').props()).toEqual({ + href: `${project.webUrl}/-/starrers`, + tooltipText: 'Stars', + iconName: 'star-o', + stat: project.starCount.toString(), + }); }); describe.each` @@ -233,13 +236,12 @@ describe('ProjectsListItem', () => { }, }); - const mergeRequestsLink = findMergeRequestsLink(); - const tooltip = getBinding(mergeRequestsLink.element, 'gl-tooltip'); - - expect(tooltip.value).toBe(ProjectsListItem.i18n.mergeRequests); - expect(mergeRequestsLink.attributes('href')).toBe(`${project.webUrl}/-/merge_requests`); - expect(mergeRequestsLink.text()).toBe('5'); - expect(mergeRequestsLink.findComponent(GlIcon).props('name')).toBe('merge-request'); + expect(findMergeRequestsStat().props()).toEqual({ + href: `${project.webUrl}/-/merge_requests`, + tooltipText: 'Merge requests', + iconName: 'merge-request', + stat: '5', + }); }); }); @@ -254,7 +256,7 @@ describe('ProjectsListItem', () => { }, }); - expect(findMergeRequestsLink().exists()).toBe(false); + expect(findMergeRequestsStat().exists()).toBe(false); }); }); @@ -262,13 +264,12 @@ describe('ProjectsListItem', () => { it('renders issues count', () => { createComponent(); - const issuesLink = findIssuesLink(); - const tooltip = getBinding(issuesLink.element, 'gl-tooltip'); - - expect(tooltip.value).toBe(ProjectsListItem.i18n.issues); - expect(issuesLink.attributes('href')).toBe(`${project.webUrl}/-/issues`); - expect(issuesLink.text()).toBe(project.openIssuesCount.toString()); - expect(issuesLink.findComponent(GlIcon).props('name')).toBe('issues'); + expect(findIssuesStat().props()).toEqual({ + href: `${project.webUrl}/-/issues`, + tooltipText: 'Issues', + iconName: 'issues', + stat: project.openIssuesCount.toString(), + }); }); }); @@ -283,7 +284,7 @@ describe('ProjectsListItem', () => { }, }); - expect(findIssuesLink().exists()).toBe(false); + expect(findIssuesStat().exists()).toBe(false); }); }); @@ -291,13 +292,12 @@ describe('ProjectsListItem', () => { it('renders forks count', () => { createComponent(); - const forksLink = findForksLink(); - const tooltip = getBinding(forksLink.element, 'gl-tooltip'); - - expect(tooltip.value).toBe(ProjectsListItem.i18n.forks); - expect(forksLink.attributes('href')).toBe(`${project.webUrl}/-/forks`); - expect(forksLink.text()).toBe(project.openIssuesCount.toString()); - expect(forksLink.findComponent(GlIcon).props('name')).toBe('fork'); + expect(findForksStat().props()).toEqual({ + href: `${project.webUrl}/-/forks`, + tooltipText: 'Forks', + iconName: 'fork', + stat: project.forksCount.toString(), + }); }); }); @@ -320,7 +320,7 @@ describe('ProjectsListItem', () => { }, }); - expect(findForksLink().exists()).toBe(false); + expect(findForksStat().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_description_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_description_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..96b2ea35aa7c905ab94d6e12a61f449db90bd882 --- /dev/null +++ b/spec/frontend/vue_shared/components/resource_lists/list_item_description_spec.js @@ -0,0 +1,25 @@ +import { GlTruncateText } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ListItemDescription from '~/vue_shared/components/resource_lists/list_item_description.vue'; + +describe('ListItemDescription', () => { + let wrapper; + + const defaultPropsData = { + descriptionHtml: '<p>Dolorem dolorem omnis impedit cupiditate pariatur officia velit.</p>', + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(ListItemDescription, { + propsData: { ...defaultPropsData, ...propsData }, + }); + }; + + it('renders description', () => { + createComponent(); + + expect(wrapper.findComponent(GlTruncateText).element.firstChild.innerHTML).toBe( + defaultPropsData.descriptionHtml, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js index 559e777360cdf2eab365c53f5b051940c217ee20..257de50d53559eed2a770ec3d5ef6e8fe58e7ab9 100644 --- a/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js +++ b/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js @@ -1,6 +1,7 @@ import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ListItem from '~/vue_shared/components/resource_lists/list_item.vue'; +import ListItemDescription from '~/vue_shared/components/resource_lists/list_item_description.vue'; import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; @@ -27,20 +28,21 @@ describe('ListItem', () => { resource, }; - const createComponent = ({ propsData = {}, stubs = {} } = {}) => { + const createComponent = ({ propsData = {}, stubs = {}, scopedSlots = {} } = {}) => { wrapper = shallowMountExtended(ListItem, { propsData: { ...defaultPropsData, ...propsData }, scopedSlots: { 'avatar-meta': '<div data-testid="avatar-meta"></div>', stats: '<div data-testid="stats"></div>', footer: '<div data-testid="footer"></div>', + ...scopedSlots, }, stubs, }); }; const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); - const findGroupDescription = () => wrapper.findByTestId('description'); + const findDescription = () => wrapper.findComponent(ListItemDescription); const findListActions = () => wrapper.findComponent(ListActions); const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); @@ -80,35 +82,47 @@ describe('ListItem', () => { expect(wrapper.findByTestId('footer').exists()).toBe(true); }); - describe('when resource has a description', () => { - it('renders description', () => { - const descriptionHtml = '<p>Foo bar</p>'; - + describe('when avatar-default slot is provided', () => { + beforeEach(() => { createComponent({ - propsData: { - resource: { - ...resource, - descriptionHtml, - }, - }, + scopedSlots: { 'avatar-default': '<div data-testid="avatar-default"></div>' }, }); + }); - expect(findGroupDescription().element.innerHTML).toBe(descriptionHtml); + it('renders slot instead of description', () => { + expect(wrapper.findByTestId('avatar-default').exists()).toBe(true); + expect(findDescription().exists()).toBe(false); }); }); - describe('when resource does not have a description', () => { - it('does not render description', () => { - createComponent({ - propsData: { - resource: { - ...resource, - descriptionHtml: null, + describe('when avatar-default slot is not provided', () => { + describe('when resource has a description', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders description', () => { + expect(findDescription().props('descriptionHtml')).toBe( + defaultPropsData.resource.descriptionHtml, + ); + }); + }); + + describe('when resource does not have a description', () => { + beforeEach(() => { + createComponent({ + propsData: { + resource: { + ...resource, + descriptionHtml: null, + }, }, - }, + }); }); - expect(findGroupDescription().exists()).toBe(false); + it('does not render description', () => { + expect(findDescription().exists()).toBe(false); + }); }); }); @@ -130,17 +144,37 @@ describe('ListItem', () => { }); }); - describe('when resource has available actions', () => { - it('displays actions dropdown', () => { - createComponent({ - propsData: { + describe('when actions prop is passed', () => { + describe('when resource has available actions', () => { + it('displays actions dropdown', () => { + createComponent({ + propsData: { + actions, + }, + }); + + expect(findListActions().props()).toMatchObject({ actions, - }, + availableActions: resource.availableActions, + }); + }); + }); + + describe('when resource does not have available actions', () => { + beforeEach(() => { + createComponent({ + propsData: { + actions, + resource: { + ...resource, + availableActions: [], + }, + }, + }); }); - expect(findListActions().props()).toMatchObject({ - actions, - availableActions: resource.availableActions, + it('does not display actions dropdown', () => { + expect(findListActions().exists()).toBe(false); }); }); }); @@ -155,12 +189,20 @@ describe('ListItem', () => { }); }); - describe('when resource does not have available actions', () => { + describe('when actions slot is provided', () => { beforeEach(() => { - createComponent(); + createComponent({ + propsData: { + actions, + }, + scopedSlots: { + actions: '<div data-testid="actions"></div>', + }, + }); }); - it('does not display actions dropdown', () => { + it('renders slot instead of list actions component', () => { + expect(wrapper.findByTestId('actions').exists()).toBe(true); expect(findListActions().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js index 4d3f0d713cd61c1de3efa130e405ea48543ab47f..6b833e2339c2ce724586cb1296921d731c39fc36 100644 --- a/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js +++ b/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js @@ -1,4 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlLink } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ListItemStat from '~/vue_shared/components/resource_lists/list_item_stat.vue'; @@ -21,13 +21,26 @@ describe('ListItemStat', () => { }); }; - it('renders stat with icon and tooltip', () => { + it('renders stat in div with icon and tooltip', () => { createComponent(); const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(wrapper.element.tagName).toBe('DIV'); expect(wrapper.text()).toBe(defaultPropsData.stat); expect(tooltip.value).toBe(defaultPropsData.tooltipText); expect(wrapper.findComponent(GlIcon).props('name')).toBe(defaultPropsData.iconName); }); + + describe('when href prop is passed', () => { + const href = 'http://gdk.test:3000/foo/bar/-/forks`'; + + beforeEach(() => { + createComponent({ propsData: { href } }); + }); + + it('renders `GlLink` component', () => { + expect(wrapper.findComponent(GlLink).attributes('href')).toBe(href); + }); + }); });