diff options
Diffstat (limited to 'app/assets/javascripts/boards/components')
9 files changed, 306 insertions, 140 deletions
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue index 0b9243c07c5..ca8299ddf80 100644 --- a/app/assets/javascripts/boards/components/board_app.vue +++ b/app/assets/javascripts/boards/components/board_app.vue @@ -5,9 +5,11 @@ import { s__ } from '~/locale'; import BoardContent from '~/boards/components/board_content.vue'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import BoardTopBar from '~/boards/components/board_top_bar.vue'; +import eventHub from '~/boards/eventhub'; import { listsQuery } from 'ee_else_ce/boards/constants'; import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; +import errorQuery from '../graphql/client/error.query.graphql'; export default { i18n: { @@ -38,6 +40,7 @@ export default { addColumnFormVisible: false, isShowingEpicsSwimlanes: Boolean(queryToObject(window.location.search).group_by), apolloError: null, + error: null, }; }, apollo: { @@ -75,6 +78,10 @@ export default { this.apolloError = this.$options.i18n.fetchError; }, }, + error: { + query: errorQuery, + update: (data) => data.boardsAppError, + }, }, computed: { @@ -106,11 +113,16 @@ export default { }, created() { window.addEventListener('popstate', refreshCurrentPage); + eventHub.$on('updateBoard', this.refetchLists); }, destroyed() { window.removeEventListener('popstate', refreshCurrentPage); + eventHub.$off('updateBoard', this.refetchLists); }, methods: { + refetchLists() { + this.$apollo.queries.boardListsApollo.refetch(); + }, setActiveId(id) { this.activeListId = id; }, @@ -145,7 +157,7 @@ export default { :is-swimlanes-on="isSwimlanesOn" :filter-params="filterParams" :board-lists-apollo="boardListsApollo" - :apollo-error="apolloError" + :apollo-error="apolloError || error" :list-query-variables="listQueryVariables" @setActiveList="setActiveId" @setAddColumnFormVisibility="addColumnFormVisible = $event" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index befd04c29ae..6036f0c359c 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -43,7 +43,14 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [boardCardInner], - inject: ['rootPath', 'scopedLabelsAvailable', 'isEpicBoard', 'issuableType', 'isGroupBoard'], + inject: [ + 'rootPath', + 'scopedLabelsAvailable', + 'isEpicBoard', + 'issuableType', + 'isGroupBoard', + 'isApolloBoard', + ], props: { item: { type: Object, @@ -78,6 +85,9 @@ export default { }, computed: { ...mapState(['isShowingLabels', 'allowSubEpics']), + isLoading() { + return this.item.isLoading || this.item.iid === '-1'; + }, cappedAssignees() { // e.g. maxRender is 4, // Render up to all 4 assignees if there are only 4 assigness @@ -201,7 +211,9 @@ export default { updateHistory({ url: `${filterPath}${filter}`, }); - this.performSearch(); + if (!this.isApolloBoard) { + this.performSearch(); + } eventHub.$emit('updateTokens'); } }, @@ -243,7 +255,7 @@ export default { <a :href="item.path || item.webUrl || ''" :title="item.title" - :class="{ 'gl-text-gray-400!': item.isLoading }" + :class="{ 'gl-text-gray-400!': isLoading }" class="js-no-trigger gl-text-body gl-hover-text-gray-900" @mousemove.stop >{{ item.title }}</a @@ -272,9 +284,9 @@ export default { <div class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden" > - <gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" /> <span - v-if="item.referencePath" + v-if="item.referencePath && !isLoading" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-font-sm gl-text-secondary" :class="{ 'gl-font-base': isEpicBoard }" > diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index a51e4ddc8f8..14c781f588f 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -4,7 +4,6 @@ import { sortBy } from 'lodash'; import produce from 'immer'; import Draggable from 'vuedraggable'; import { mapState, mapActions } from 'vuex'; -import eventHub from '~/boards/eventhub'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { defaultSortableOptions } from '~/sortable/constants'; import { @@ -107,24 +106,15 @@ export default { return this.canDragColumns ? options : {}; }, errorToDisplay() { - return this.isApolloBoard ? this.apolloError : this.error; + return this.apolloError || this.error || null; }, }, - created() { - eventHub.$on('updateBoard', this.refetchLists); - }, - beforeDestroy() { - eventHub.$off('updateBoard', this.refetchLists); - }, methods: { ...mapActions(['moveList', 'unsetError']), afterFormEnters() { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, - refetchLists() { - this.$apollo.queries.boardListsApollo.refetch(); - }, highlightList(listId) { this.highlightedLists.push(listId); diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 604e71f5993..9ea801dc9a2 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -226,10 +226,12 @@ export default { } this.cancel(); - const param = getParameterByName('group_by') - ? `?group_by=${getParameterByName('group_by')}` - : ''; - updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); + if (!this.isApolloBoard) { + const param = getParameterByName('group_by') + ? `?group_by=${getParameterByName('group_by')}` + : ''; + updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` }); + } } catch { this.setError({ message: this.$options.i18n.saveErrorMessage }); } finally { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index af309ba9912..b4249c63b4d 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -22,6 +22,7 @@ import { removeItemFromList, updateEpicsCount, updateIssueCountAndWeight, + setError, } from '../graphql/cache_updates'; import { shouldCloneCard, moveItemVariables } from '../boards_util'; import eventHub from '../eventhub'; @@ -33,7 +34,7 @@ export default { name: 'BoardList', i18n: { loading: __('Loading'), - loadingMoreboardItems: __('Loading more'), + loadingMoreBoardItems: __('Loading more'), showingAllIssues: __('Showing all issues'), showingAllEpics: __('Showing all epics'), }, @@ -83,6 +84,7 @@ export default { isLoadingMore: false, toListId: null, toList: {}, + addItemToListInProgress: false, }; }, apollo: { @@ -213,7 +215,8 @@ export default { return !this.disabled; }, treeRootWrapper() { - return this.canMoveIssue && !this.listsFlags[this.list.id]?.addItemToListInProgress + return this.canMoveIssue && + (!this.listsFlags[this.list.id]?.addItemToListInProgress || this.addItemToListInProgress) ? Draggable : 'ul'; }, @@ -468,14 +471,14 @@ export default { this.updateCountAndWeight({ fromListId, toListId, issuable, cache }); }, - updateCountAndWeight({ fromListId, toListId, issuable, isAddingIssue, cache }) { + updateCountAndWeight({ fromListId, toListId, issuable, isAddingItem, cache }) { if (!this.isEpicBoard) { updateIssueCountAndWeight({ fromListId, toListId, filterParams: this.filterParams, issuable, - shouldClone: isAddingIssue || this.shouldCloneCard, + shouldClone: isAddingItem || this.shouldCloneCard, cache, }); } else { @@ -486,7 +489,7 @@ export default { fromListId, filterParams, issuable, - shouldClone: this.shouldCloneCard, + shouldClone: isAddingItem || this.shouldCloneCard, cache, }); } @@ -538,6 +541,59 @@ export default { }, }); }, + async addListItem(input) { + this.toggleForm(); + this.addItemToListInProgress = true; + try { + await this.$apollo.mutate({ + mutation: listIssuablesQueries[this.issuableType].createMutation, + variables: { + input: this.isEpicBoard ? input : { ...input, moveAfterId: this.boardListItems[0]?.id }, + withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight, + }, + update: (cache, { data: { createIssuable } }) => { + const { issuable } = createIssuable; + addItemToList({ + query: listIssuablesQueries[this.issuableType].query, + variables: { ...this.listQueryVariables, id: this.currentList.id }, + issuable, + newIndex: 0, + boardType: this.boardType, + issuableType: this.issuableType, + cache, + }); + this.updateCountAndWeight({ + fromListId: null, + toListId: this.list.id, + issuable, + isAddingItem: true, + cache, + }); + }, + optimisticResponse: { + createIssuable: { + errors: [], + issuable: { + ...listIssuablesQueries[this.issuableType].optimisticResponse, + title: input.title, + }, + }, + }, + }); + } catch (error) { + setError({ + message: sprintf( + __('An error occurred while creating the %{issuableType}. Please try again.'), + { + issuableType: this.isEpicBoard ? 'epic' : 'issue', + }, + ), + error, + }); + } finally { + this.addItemToListInProgress = false; + } + }, }, }; </script> @@ -556,8 +612,18 @@ export default { > <gl-loading-icon size="sm" /> </div> - <board-new-issue v-if="issueCreateFormVisible" :list="list" /> - <board-new-epic v-if="epicCreateFormVisible" :list="list" /> + <board-new-issue + v-if="issueCreateFormVisible" + :list="list" + :board-id="boardId" + @addNewIssue="addListItem" + /> + <board-new-epic + v-if="epicCreateFormVisible" + :list="list" + :board-id="boardId" + @addNewEpic="addListItem" + /> <component :is="treeRootWrapper" v-show="!loading" @@ -610,7 +676,7 @@ export default { <gl-loading-icon v-if="loadingMore" size="sm" - :label="$options.i18n.loadingMoreboardItems" + :label="$options.i18n.loadingMoreBoardItems" /> <span v-if="showingAllItems">{{ showingAllItemsText }}</span> <span v-else>{{ paginatedIssueText }}</span> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 61a9b22bfc5..8db86d0e894 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -1,12 +1,12 @@ <script> import { GlButton, + GlButtonGroup, GlLabel, GlTooltip, GlIcon, GlSprintf, GlTooltipDirective, - GlDisclosureDropdown, } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import { isListDraggable } from '~/boards/boards_util'; @@ -35,15 +35,14 @@ import ItemCount from './item_count.vue'; export default { i18n: { newIssue: s__('Boards|Create new issue'), - listActions: s__('Boards|List actions'), newEpic: s__('Boards|Create new epic'), listSettings: s__('Boards|Edit list settings'), expand: s__('Boards|Expand'), collapse: s__('Boards|Collapse'), }, components: { - GlDisclosureDropdown, GlButton, + GlButtonGroup, GlLabel, GlTooltip, GlIcon, @@ -194,50 +193,6 @@ export default { canShowTotalWeight() { return this.weightFeatureAvailable && !this.isLoading; }, - actionListItems() { - const items = []; - - if (this.isNewIssueShown) { - const newIssueText = this.$options.i18n.newIssue; - items.push({ - text: newIssueText, - action: this.showNewIssueForm, - extraAttrs: { - 'data-testid': 'newIssueBtn', - title: newIssueText, - 'aria-label': newIssueText, - }, - }); - } - - if (this.isNewEpicShown) { - const newEpicText = this.$options.i18n.newEpic; - items.push({ - text: newEpicText, - action: this.showNewEpicForm, - extraAttrs: { - 'data-testid': 'newEpicBtn', - title: newEpicText, - 'aria-label': newEpicText, - }, - }); - } - - if (this.isSettingsShown) { - const listSettingsText = this.$options.i18n.listSettings; - items.push({ - text: listSettingsText, - action: this.openSidebarSettings, - extraAttrs: { - 'data-testid': 'settingsBtn', - title: listSettingsText, - 'aria-label': listSettingsText, - }, - }); - } - - return items; - }, }, apollo: { boardList: { @@ -525,23 +480,42 @@ export default { <!-- EE end --> </span> </div> - <gl-disclosure-dropdown - v-if="showListHeaderActions" - v-gl-tooltip.hover.top="{ - title: $options.i18n.listActions, - boundary: 'viewport', - }" - data-testid="header-list-actions" - class="gl-py-2 gl-ml-3" - :aria-label="$options.i18n.listActions" - :title="$options.i18n.listActions" - category="tertiary" - icon="ellipsis_v" - :text-sr-only="true" - :items="actionListItems" - no-caret - placement="right" - /> + <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2"> + <gl-button + v-if="isNewIssueShown" + ref="newIssueBtn" + v-gl-tooltip.hover + :aria-label="$options.i18n.newIssue" + :title="$options.i18n.newIssue" + size="small" + icon="plus" + data-testid="new-issue-btn" + @click="showNewIssueForm" + /> + + <gl-button + v-if="isNewEpicShown" + v-gl-tooltip.hover + :aria-label="$options.i18n.newEpic" + :title="$options.i18n.newEpic" + size="small" + icon="plus" + data-testid="new-epic-btn" + @click="showNewEpicForm" + /> + + <gl-button + v-if="isSettingsShown" + ref="settingsBtn" + v-gl-tooltip.hover + :aria-label="$options.i18n.listSettings" + size="small" + :title="$options.i18n.listSettings" + icon="settings" + data-testid="settings-btn" + @click="openSidebarSettings" + /> + </gl-button-group> </h3> </header> </template> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 8b9fafca306..b68444fb011 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,30 +1,73 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import { getMilestone } from 'ee_else_ce/boards/boards_util'; +import { mapActions, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import { getMilestone, formatIssueInput, getBoardQuery } from 'ee_else_ce/boards/boards_util'; import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; import { toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; +import { setError } from '../graphql/cache_updates'; import BoardNewItem from './board_new_item.vue'; import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', + i18n: { + errorFetchingBoard: s__('Boards|An error occurred while fetching board. Please try again.'), + }, components: { BoardNewItem, ProjectSelect, }, mixins: [BoardNewIssueMixin], - inject: ['groupId', 'fullPath', 'isGroupBoard'], + inject: ['boardType', 'groupId', 'fullPath', 'isGroupBoard', 'isEpicBoard', 'isApolloBoard'], props: { list: { type: Object, required: true, }, + boardId: { + type: String, + required: true, + }, + }, + data() { + return { + selectedProject: {}, + board: {}, + }; + }, + apollo: { + board: { + query() { + return getBoardQuery(this.boardType, this.isEpicBoard); + }, + variables() { + return { + fullPath: this.fullPath, + boardId: this.boardId, + }; + }, + skip() { + return !this.isApolloBoard; + }, + update(data) { + const { board } = data.workspace; + return { + ...board, + labels: board.labels?.nodes, + }; + }, + error(error) { + setError({ + error, + message: this.$options.i18n.errorFetchingBoard, + }); + }, + }, }, computed: { - ...mapState(['selectedProject']), ...mapGetters(['getBoardItemsByList']), formEventPrefix() { return toggleFormEventPrefix.issue; @@ -42,8 +85,20 @@ export default { const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); - const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id; + if (this.isApolloBoard) { + return this.addNewIssueToList({ + issueInput: { + title, + labelIds: labels?.map((l) => l.id), + assigneeIds: assignees?.map((a) => a?.id), + milestoneId: milestone?.id, + projectPath: this.projectPath, + }, + }); + } + + const firstItemId = this.getBoardItemsByList(this.list.id)[0]?.id; return this.addListNewIssue({ list: this.list, issueInput: { @@ -58,6 +113,22 @@ export default { this.cancel(); }); }, + addNewIssueToList({ issueInput }) { + const { labels, assignee, milestone, weight } = this.board; + const config = { + labels, + assigneeId: assignee?.id || null, + milestoneId: milestone?.id || null, + weight, + }; + const input = formatIssueInput(issueInput, config); + + if (!this.isGroupBoard) { + input.projectPath = this.fullPath; + } + + this.$emit('addNewIssue', input); + }, cancel() { eventHub.$emit(`${this.formEventPrefix}${this.list.id}`); }, @@ -74,6 +145,6 @@ export default { @form-submit="submit" @form-cancel="cancel" > - <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard" v-model="selectedProject" :list="list" /> </board-new-item> </template> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 3c056f296e1..f60f00be368 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -75,6 +75,7 @@ export default { type: TOKEN_TYPE_ASSIGNEE, operators: OPERATORS_IS_NOT, token: UserToken, + dataType: 'user', unique: true, fetchUsers, preloadedUsers: this.preloadedUsers(), @@ -86,6 +87,7 @@ export default { operators: OPERATORS_IS_NOT, symbol: '@', token: UserToken, + dataType: 'user', unique: true, fetchUsers, preloadedUsers: this.preloadedUsers(), diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 960c8e472b8..7bbc444701a 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,11 +1,8 @@ <script> import { GlCollapsibleListbox } from '@gitlab/ui'; -import { mapActions, mapGetters, mapState } from 'vuex'; -import { debounce } from 'lodash'; import { s__ } from '~/locale'; -import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { ListType } from '../constants'; +import groupProjectsQuery from '../graphql/group_projects.query.graphql'; +import { setError } from '../graphql/cache_updates'; export default { name: 'ProjectSelect', @@ -14,6 +11,9 @@ export default { dropdownText: s__(`BoardNewIssue|Select a project`), searchPlaceholder: s__(`BoardNewIssue|Search projects`), emptySearchResult: s__(`BoardNewIssue|No matching results`), + errorFetchingProjects: s__( + 'Boards|An error occurred while fetching group projects. Please try again.', + ), }, defaultFetchOptions: { with_issues_enabled: true, @@ -24,70 +24,107 @@ export default { components: { GlCollapsibleListbox, }, - inject: ['groupId'], + inject: ['groupId', 'fullPath'], + model: { + prop: 'selectedProject', + event: 'selectProject', + }, props: { list: { type: Object, required: true, }, + selectedProject: { + type: Object, + required: true, + }, }, data() { return { initialLoading: true, selectedProjectId: '', - selectedProject: {}, searchTerm: '', + projects: {}, + isLoadingMore: false, }; }, + apollo: { + projects: { + query: groupProjectsQuery, + variables() { + return { + fullPath: this.fullPath, + search: this.searchTerm, + }; + }, + update(data) { + return data.group.projects; + }, + error(error) { + setError({ + error, + message: this.$options.i18n.errorFetchingProjects, + }); + }, + result() { + this.initialLoading = false; + }, + }, + }, computed: { - ...mapState(['groupProjectsFlags']), - ...mapGetters(['activeGroupProjects']), - projects() { - return this.activeGroupProjects.map((project) => ({ - value: project.id, - text: project.nameWithNamespace, - })); + isLoading() { + return this.$apollo.queries.projects.loading && !this.isLoadingMore; + }, + activeGroupProjects() { + return ( + this.projects?.nodes?.map((project) => ({ + value: project.id, + text: project.nameWithNamespace, + })) || [] + ); }, selectedProjectName() { return this.selectedProject.name || this.$options.i18n.dropdownText; }, - fetchOptions() { - const additionalAttrs = {}; - if (this.list.type && this.list.type !== ListType.backlog) { - additionalAttrs.min_access_level = featureAccessLevel.EVERYONE; - } - - return { - ...this.$options.defaultFetchOptions, - ...additionalAttrs, - }; - }, isFetchResultEmpty() { return this.activeGroupProjects.length === 0; }, hasNextPage() { - return this.groupProjectsFlags.pageInfo?.hasNextPage; + return this.projects.pageInfo?.hasNextPage; }, }, watch: { - searchTerm: debounce(function debouncedSearch() { - this.fetchGroupProjects({ search: this.searchTerm }); - }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - }, - mounted() { - this.fetchGroupProjects({}); - this.initialLoading = false; + endCursor() { + return this.projects.pageInfo?.endCursor; + }, }, methods: { - ...mapActions(['fetchGroupProjects', 'setSelectedProject']), selectProject(projectId) { this.selectedProjectId = projectId; - this.selectedProject = this.activeGroupProjects.find((project) => project.id === projectId); - this.setSelectedProject(this.selectedProject); + this.$emit( + 'selectProject', + this.projects.nodes.find((project) => project.id === projectId), + ); }, - loadMoreProjects() { + async loadMoreProjects() { if (!this.hasNextPage) return; - this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true }); + this.isLoadingMore = true; + try { + await this.$apollo.queries.projects.fetchMore({ + variables: { + fullPath: this.fullPath, + search: this.searchTerm, + after: this.endCursor, + }, + }); + } catch (error) { + setError({ + error, + message: this.$options.i18n.errorFetchingProjects, + }); + } finally { + this.isLoadingMore = false; + } }, onSearch(query) { this.searchTerm = query; @@ -107,14 +144,14 @@ export default { searchable infinite-scroll data-testid="project-select-dropdown" - :items="projects" + :items="activeGroupProjects" :toggle-text="selectedProjectName" :header-text="$options.i18n.headerTitle" :loading="initialLoading" - :searching="groupProjectsFlags.isLoading" + :searching="isLoading" :search-placeholder="$options.i18n.searchPlaceholder" :no-results-text="$options.i18n.emptySearchResult" - :infinite-scroll-loading="groupProjectsFlags.isLoadingMore" + :infinite-scroll-loading="isLoadingMore" @select="selectProject" @search="onSearch" @bottom-reached="loadMoreProjects" |