diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4d1c171772e97393e26b426e7903daee29d156f5..4692096941571d8c22c7672ac5dbcc1ca8943509 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -1,10 +1,23 @@ <script> -import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui'; +import { GlTokenSelector, GlIcon, GlAvatar, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import { n__ } from '~/locale'; +import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; +import { i18n } from '../constants'; -function isClosingIcon(el) { - return el?.classList.contains('gl-token-close'); +function isTokenSelectorElement(el) { + return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item'); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; } export default { @@ -13,7 +26,10 @@ export default { GlIcon, GlAvatar, GlLink, + GlSkeletonLoader, + SidebarParticipant, }, + inject: ['fullPath'], props: { workItemId: { type: String, @@ -27,45 +43,95 @@ export default { data() { return { isEditing: false, - localAssignees: this.assignees.map((assignee) => ({ - ...assignee, - class: 'gl-bg-transparent!', - })), + searchStarted: false, + localAssignees: this.assignees.map(addClass), + searchKey: '', + searchUsers: [], }; }, - computed: { - assigneeIds() { - return this.localAssignees.map((assignee) => assignee.id); + apollo: { + searchUsers: { + query() { + return userSearchQuery; + }, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user })); + }, + error() { + this.$emit('error', i18n.fetchError); + }, }, + }, + computed: { assigneeListEmpty() { return this.assignees.length === 0; }, containerClass() { return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : ''; }, + isLoading() { + return this.$apollo.queries.searchUsers.loading; + }, + assigneeText() { + return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length); + }, + }, + watch: { + assignees(newVal) { + if (!this.isEditing) { + this.localAssignees = newVal.map(addClass); + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, methods: { getUserId(id) { return getIdFromGraphQLId(id); }, setAssignees(e) { - if (isClosingIcon(e.relatedTarget) || !this.isEditing) return; + if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return; this.isEditing = false; this.$apollo.mutate({ mutation: localUpdateWorkItemMutation, variables: { input: { id: this.workItemId, - assigneeIds: this.assigneeIds, + assignees: this.localAssignees, }, }, }); }, - async focusTokenSelector() { + handleFocus() { this.isEditing = true; + this.searchStarted = true; + }, + async focusTokenSelector() { + this.handleFocus(); await this.$nextTick(); this.$refs.tokenSelector.focusTextInput(); }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, }, }; </script> @@ -73,17 +139,21 @@ export default { <template> <div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative"> <span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{ - __('Assignee(s)') + assigneeText }}</span> <gl-token-selector ref="tokenSelector" v-model="localAssignees" - hide-dropdown-with-no-items :container-class="containerClass" + :dropdown-items="searchUsers" + :loading="isLoading" class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base" - @token-remove="focusTokenSelector" - @focus="isEditing = true" + @input="focusTokenSelector" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" @blur="setAssignees" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" > <template #empty-placeholder> <div @@ -106,6 +176,17 @@ export default { <span class="gl-pl-2">{{ token.name }}</span> </gl-link> </template> + <template #dropdown-item-content="{ dropdownItem }"> + <sidebar-participant :user="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> </gl-token-selector> </div> </template> diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 09d929faae20815b82ec4dc4d61c3517182fb149..9266b4cdccba3e79c588cd4700d17e186f814f0d 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -70,9 +70,7 @@ export const resolvers = { const assigneesWidget = draftData.workItem.mockWidgets.find( (widget) => widget.type === WIDGET_TYPE_ASSIGNEE, ); - assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) => - input.assigneeIds.includes(assignee.id), - ); + assigneesWidget.nodes = [...input.assignees]; }); cache.writeQuery({ diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index bfe2f0fe0ce20e6b8a7309aed73d8b3ab6ea734b..de4bdad565964322c00af8b953ad9cacf2240e98 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -23,7 +23,7 @@ extend type WorkItem { type LocalWorkItemAssigneesInput { id: WorkItemID! - assigneeIds: [ID!] + assignees: [UserCore!] } type LocalWorkItemPayload { diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index be72ec334658090e60e09b7582f1adb384247b61..cf4a415446e066ade264865621028481d8d88465 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -32,3 +32,4 @@ @import './pages/storage_quota'; @import './pages/tree'; @import './pages/users'; +@import './pages/work_items'; diff --git a/app/assets/stylesheets/pages/work_items.scss b/app/assets/stylesheets/pages/work_items.scss new file mode 100644 index 0000000000000000000000000000000000000000..b98f55df1ed01fc3ffc0b1e0847197c6b53eab84 --- /dev/null +++ b/app/assets/stylesheets/pages/work_items.scss @@ -0,0 +1,4 @@ +.gl-token-selector-token-container { + display: flex; + align-items: center; +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b97c4e6ec289d3af884ceab23f6375b75f9f02dd..ecea30cb802a949a44842e9f73f0b919d0084b64 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43507,6 +43507,11 @@ msgstr "" msgid "WorkItem|Are you sure you want to delete the work item? This action cannot be reversed." msgstr "" +msgid "WorkItem|Assignee" +msgid_plural "WorkItem|Assignees" +msgstr[0] "" +msgstr[1] "" + msgid "WorkItem|Cancel" msgstr "" diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 0552fe5050e75415d396f38c8e4b38d99cf8e26c..b2678293c05928a21d7bc3c65c8e17c326eec710 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -1,52 +1,59 @@ -import { GlLink, GlTokenSelector } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import { GlLink, GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; -import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql'; - -const mockAssignees = [ - { - __typename: 'UserCore', - id: 'gid://gitlab/User/1', - avatarUrl: '', - webUrl: '', - name: 'John Doe', - username: 'doe_I', - }, - { - __typename: 'UserCore', - id: 'gid://gitlab/User/2', - avatarUrl: '', - webUrl: '', - name: 'Marcus Rutherford', - username: 'ruthfull', - }, -]; +import { i18n } from '~/work_items/constants'; +import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { projectMembersResponse, mockAssignees, workItemQueryResponse } from '../mock_data'; -const workItemId = 'gid://gitlab/WorkItem/1'; +Vue.use(VueApollo); -const mutate = jest.fn(); +const workItemId = 'gid://gitlab/WorkItem/1'; describe('WorkItemAssignees component', () => { let wrapper; const findAssigneeLinks = () => wrapper.findAllComponents(GlLink); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findEmptyState = () => wrapper.findByTestId('empty-state'); - const createComponent = ({ assignees = mockAssignees } = {}) => { + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + assignees = mockAssignees, + searchQueryHandler = successSearchQueryHandler, + } = {}) => { + const apolloProvider = createMockApollo([[userSearchQuery, searchQueryHandler]], resolvers, { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); + wrapper = mountExtended(WorkItemAssignees, { + provide: { + fullPath: 'test-project-path', + }, propsData: { assignees, workItemId, }, - mocks: { - $apollo: { - mutate, - }, - }, attachTo: document.body, + apolloProvider, }); }; @@ -54,40 +61,114 @@ describe('WorkItemAssignees component', () => { wrapper.destroy(); }); - it('should pass the correct data-user-id attribute', () => { + it('passes the correct data-user-id attribute', () => { createComponent(); expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1'); }); - describe('when there are assignees', () => { - beforeEach(() => { - createComponent(); - }); + it('focuses token selector on token selector input event', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + await nextTick(); - it('should focus token selector on token removal', async () => { - findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id); - await nextTick(); + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); - expect(findEmptyState().exists()).toBe(false); - expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); - }); + it('calls a mutation on clicking outside the token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockAssignees[0]]); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + await waitForPromises(); - it('should call a mutation on clicking outside the token selector', async () => { - findTokenSelector().vm.$emit('input', [mockAssignees[0]]); - findTokenSelector().vm.$emit('token-remove'); - await nextTick(); - expect(mutate).not.toHaveBeenCalled(); - - findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); - await nextTick(); - - expect(mutate).toHaveBeenCalledWith({ - mutation: localUpdateWorkItemMutation, - variables: { - input: { id: workItemId, assigneeIds: [mockAssignees[0].id] }, - }, - }); - }); + expect(findTokenSelector().props('selectedTokens')).toEqual([mockAssignees[0]]); + }); + + it('does not start user search by default', () => { + createComponent(); + + expect(findTokenSelector().props('loading')).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toEqual([]); + }); + + it('starts user search on hovering for more than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('starts user search on focusing token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('does not start searching if token-selector was hovered for less than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + + findTokenSelector().trigger('mouseout'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('shows skeleton loader on dropdown when loading users', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows correct user list in dropdown when loaded', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); + + it('emits error event if search users query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + }); + + it('should search for users with correct key after text input', async () => { + const searchKey = 'Hello'; + + createComponent(); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', searchKey); + await waitForPromises(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ search: searchKey }), + ); }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 91dfc61198cdaa84f52b154b5eb790fe4c820288..116bf48901dec337cf55d35cf9791875edda1e1b 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -300,3 +300,60 @@ export const availableWorkItemsResponse = { }, }, }; + +export const projectMembersResponse = { + data: { + workspace: { + id: '1', + __typename: 'Project', + users: { + nodes: [ + { + id: 'user-1', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + }, + { + id: 'user-2', + user: { + __typename: 'UserCore', + id: 'gid://gitlab/User/5', + avatarUrl: '/avatar2', + name: 'rookie', + username: 'rookie', + webUrl: 'rookie', + status: null, + }, + }, + ], + }, + }, + }, +}; + +export const mockAssignees = [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: '', + webUrl: '', + name: 'John Doe', + username: 'doe_I', + }, + { + __typename: 'UserCore', + id: 'gid://gitlab/User/2', + avatarUrl: '', + webUrl: '', + name: 'Marcus Rutherford', + username: 'ruthfull', + }, +];