debian-mirror-gitlab/app/assets/javascripts/issues_list/components/issues_list_app.vue
2021-11-18 22:05:49 +05:30

803 lines
24 KiB
Vue

<script>
import {
GlButton,
GlEmptyState,
GlFilteredSearchToken,
GlIcon,
GlLink,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
import createFlash from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
CREATED_DESC,
i18n,
MAX_LIST_SIZE,
PAGE_SIZE,
PARAM_DUE_DATE,
PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_ASC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
TOKEN_TYPE_EPIC,
TOKEN_TYPE_ITERATION,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_TYPE,
TOKEN_TYPE_WEIGHT,
UPDATED_DESC,
urlSortParams,
} from '~/issues_list/constants';
import {
convertToApiParams,
convertToSearchQuery,
convertToUrlParams,
getDueDateValue,
getFilterTokens,
getInitialPageParams,
getSortKey,
getSortOptions,
} from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
import {
DEFAULT_NONE_ANY,
OPERATOR_IS_ONLY,
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
TOKEN_TITLE_CONFIDENTIAL,
TOKEN_TITLE_EPIC,
TOKEN_TITLE_ITERATION,
TOKEN_TITLE_LABEL,
TOKEN_TITLE_MILESTONE,
TOKEN_TITLE_MY_REACTION,
TOKEN_TITLE_TYPE,
TOKEN_TITLE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
import eventHub from '../eventhub';
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
import searchIterationsQuery from '../queries/search_iterations.query.graphql';
import searchLabelsQuery from '../queries/search_labels.query.graphql';
import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
import searchUsersQuery from '../queries/search_users.query.graphql';
import IssueCardTimeInfo from './issue_card_time_info.vue';
import NewIssueDropdown from './new_issue_dropdown.vue';
const AuthorToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue');
const EmojiToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
const EpicToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue');
const IterationToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue');
const LabelToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue');
const MilestoneToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
const WeightToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue');
export default {
i18n,
IssuableListTabs,
components: {
CsvImportExportButtons,
GlButton,
GlEmptyState,
GlIcon,
GlLink,
GlSprintf,
IssuableByEmail,
IssuableList,
IssueCardTimeInfo,
NewIssueDropdown,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
autocompleteAwardEmojisPath: {
default: '',
},
calendarPath: {
default: '',
},
canBulkUpdate: {
default: false,
},
emptyStateSvgPath: {
default: '',
},
exportCsvPath: {
default: '',
},
fullPath: {
default: '',
},
groupPath: {
default: '',
},
hasAnyIssues: {
default: false,
},
hasAnyProjects: {
default: false,
},
hasBlockedIssuesFeature: {
default: false,
},
hasIssueWeightsFeature: {
default: false,
},
hasIterationsFeature: {
default: false,
},
hasMultipleIssueAssigneesFeature: {
default: false,
},
initialEmail: {
default: '',
},
isProject: {
default: false,
},
isSignedIn: {
default: false,
},
jiraIntegrationPath: {
default: '',
},
newIssuePath: {
default: '',
},
rssPath: {
default: '',
},
showNewIssueLink: {
default: false,
},
signInPath: {
default: '',
},
},
data() {
const state = getParameterByName(PARAM_STATE);
const sortKey = getSortKey(getParameterByName(PARAM_SORT));
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
return {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: getFilterTokens(window.location.search),
issues: [],
issuesCounts: {},
pageInfo: {},
pageParams: getInitialPageParams(sortKey),
showBulkEditSidebar: false,
sortKey: sortKey || defaultSortKey,
state: state || IssuableStates.Opened,
};
},
apollo: {
issues: {
query: getIssuesQuery,
variables() {
return this.queryVariables;
},
update(data) {
return data[this.namespace]?.issues.nodes ?? [];
},
result({ data }) {
this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error });
},
skip() {
return !this.hasAnyIssues;
},
debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
variables() {
return this.queryVariables;
},
update(data) {
return data[this.namespace] ?? {};
},
error(error) {
createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error });
},
skip() {
return !this.hasAnyIssues;
},
debounce: 200,
context: {
isSingleRequest: true,
},
},
},
computed: {
queryVariables() {
return {
fullPath: this.fullPath,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
search: this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
...this.apiFilterParams,
};
},
namespace() {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
},
isBulkEditButtonDisabled() {
return this.showBulkEditSidebar || !this.issues.length;
},
isManualOrdering() {
return this.sortKey === RELATIVE_POSITION_ASC;
},
isOpenTab() {
return this.state === IssuableStates.Opened;
},
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
showNewIssueDropdown() {
return !this.isProject && this.hasAnyProjects;
},
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
urlFilterParams() {
return convertToUrlParams(this.filterTokens);
},
searchQuery() {
return convertToSearchQuery(this.filterTokens) || undefined;
},
searchTokens() {
const preloadedAuthors = [];
if (gon.current_user_id) {
preloadedAuthors.push({
id: convertToGraphQLId(TYPE_USER, gon.current_user_id),
name: gon.current_user_fullname,
username: gon.current_username,
avatar_url: gon.current_user_avatar_url,
});
}
const tokens = [
{
type: TOKEN_TYPE_AUTHOR,
title: TOKEN_TITLE_AUTHOR,
icon: 'pencil',
token: AuthorToken,
dataType: 'user',
unique: true,
defaultAuthors: [],
operators: OPERATOR_IS_ONLY,
fetchAuthors: this.fetchUsers,
preloadedAuthors,
},
{
type: TOKEN_TYPE_ASSIGNEE,
title: TOKEN_TITLE_ASSIGNEE,
icon: 'user',
token: AuthorToken,
dataType: 'user',
unique: !this.hasMultipleIssueAssigneesFeature,
defaultAuthors: DEFAULT_NONE_ANY,
fetchAuthors: this.fetchUsers,
preloadedAuthors,
},
{
type: TOKEN_TYPE_MILESTONE,
title: TOKEN_TITLE_MILESTONE,
icon: 'clock',
token: MilestoneToken,
unique: true,
fetchMilestones: this.fetchMilestones,
},
{
type: TOKEN_TYPE_LABEL,
title: TOKEN_TITLE_LABEL,
icon: 'labels',
token: LabelToken,
defaultLabels: DEFAULT_NONE_ANY,
fetchLabels: this.fetchLabels,
},
{
type: TOKEN_TYPE_TYPE,
title: TOKEN_TITLE_TYPE,
icon: 'issues',
token: GlFilteredSearchToken,
operators: OPERATOR_IS_ONLY,
options: [
{ icon: 'issue-type-issue', title: 'issue', value: 'issue' },
{ icon: 'issue-type-incident', title: 'incident', value: 'incident' },
{ icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' },
],
},
];
if (this.isSignedIn) {
tokens.push({
type: TOKEN_TYPE_MY_REACTION,
title: TOKEN_TITLE_MY_REACTION,
icon: 'thumb-up',
token: EmojiToken,
unique: true,
operators: OPERATOR_IS_ONLY,
fetchEmojis: this.fetchEmojis,
});
tokens.push({
type: TOKEN_TYPE_CONFIDENTIAL,
title: TOKEN_TITLE_CONFIDENTIAL,
icon: 'eye-slash',
token: GlFilteredSearchToken,
unique: true,
operators: OPERATOR_IS_ONLY,
options: [
{ icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes },
{ icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo },
],
});
}
if (this.hasIterationsFeature) {
tokens.push({
type: TOKEN_TYPE_ITERATION,
title: TOKEN_TITLE_ITERATION,
icon: 'iteration',
token: IterationToken,
unique: true,
fetchIterations: this.fetchIterations,
});
}
if (this.groupPath) {
tokens.push({
type: TOKEN_TYPE_EPIC,
title: TOKEN_TITLE_EPIC,
icon: 'epic',
token: EpicToken,
unique: true,
symbol: '&',
idProperty: 'id',
useIdValue: true,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`,
fullPath: this.groupPath,
});
}
if (this.hasIssueWeightsFeature) {
tokens.push({
type: TOKEN_TYPE_WEIGHT,
title: TOKEN_TITLE_WEIGHT,
icon: 'weight',
token: WeightToken,
unique: true,
});
}
return tokens;
},
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
sortOptions() {
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
},
tabCounts() {
const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
return {
[IssuableStates.Opened]: openedIssues?.count,
[IssuableStates.Closed]: closedIssues?.count,
[IssuableStates.All]: allIssues?.count,
};
},
currentTabCount() {
return this.tabCounts[this.state] ?? 0;
},
urlParams() {
return {
due_date: this.dueDateFilter,
search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
...this.urlFilterParams,
};
},
},
created() {
this.cache = {};
},
mounted() {
eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
},
beforeDestroy() {
eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
},
methods: {
fetchWithCache(path, cacheName, searchKey, search, wrapData = false) {
if (this.cache[cacheName]) {
const data = search
? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey })
: this.cache[cacheName].slice(0, MAX_LIST_SIZE);
return wrapData ? Promise.resolve({ data }) : Promise.resolve(data);
}
return axios.get(path).then(({ data }) => {
this.cache[cacheName] = data;
const result = data.slice(0, MAX_LIST_SIZE);
return wrapData ? { data: result } : result;
});
},
fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
},
fetchLabels(search) {
return this.$apollo
.query({
query: searchLabelsQuery,
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
.then(({ data }) => data[this.namespace]?.labels.nodes);
},
fetchMilestones(search) {
return this.$apollo
.query({
query: searchMilestonesQuery,
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
.then(({ data }) => data[this.namespace]?.milestones.nodes);
},
fetchIterations(search) {
const id = Number(search);
const variables =
!search || Number.isNaN(id)
? { fullPath: this.fullPath, search, isProject: this.isProject }
: { fullPath: this.fullPath, id, isProject: this.isProject };
return this.$apollo
.query({
query: searchIterationsQuery,
variables,
})
.then(({ data }) => data[this.namespace]?.iterations.nodes);
},
fetchUsers(search) {
return this.$apollo
.query({
query: searchUsersQuery,
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
.then(({ data }) =>
data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user),
);
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
},
getStatus(issue) {
if (issue.closedAt && issue.moved) {
return this.$options.i18n.closedMoved;
}
if (issue.closedAt) {
return this.$options.i18n.closed;
}
return undefined;
},
handleUpdateLegacyBulkEdit() {
// If "select all" checkbox was checked, wait for all checkboxes
// to be checked before updating IssuableBulkUpdateSidebar class
this.$nextTick(() => {
eventHub.$emit('issuables:updateBulkEdit');
});
},
async handleBulkUpdateClick() {
if (!this.hasInitBulkEdit) {
const initBulkUpdateSidebar = await import(
'~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'
);
initBulkUpdateSidebar.default.init('issuable_');
const usersSelect = await import('~/users_select');
const UsersSelect = usersSelect.default;
new UsersSelect(); // eslint-disable-line no-new
this.hasInitBulkEdit = true;
}
eventHub.$emit('issuables:enableBulkEdit');
},
handleClickTab(state) {
if (this.state !== state) {
this.pageParams = getInitialPageParams(this.sortKey);
}
this.state = state;
},
handleFilter(filter) {
this.pageParams = getInitialPageParams(this.sortKey);
this.filterTokens = filter;
},
handleNextPage() {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
firstPageSize: PAGE_SIZE,
};
scrollUp();
},
handlePreviousPage() {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
lastPageSize: PAGE_SIZE,
};
scrollUp();
},
handleReorder({ newIndex, oldIndex }) {
const issueToMove = this.issues[oldIndex];
const isDragDropDownwards = newIndex > oldIndex;
const isMovingToBeginning = newIndex === 0;
const isMovingToEnd = newIndex === this.issues.length - 1;
let moveBeforeId;
let moveAfterId;
if (isDragDropDownwards) {
const afterIndex = isMovingToEnd ? newIndex : newIndex + 1;
moveBeforeId = this.issues[newIndex].id;
moveAfterId = this.issues[afterIndex].id;
} else {
const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1;
moveBeforeId = this.issues[beforeIndex].id;
moveAfterId = this.issues[newIndex].id;
}
return axios
.put(joinPaths(issueToMove.webPath, 'reorder'), {
move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId),
move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId),
group_full_path: this.isProject ? undefined : this.fullPath,
})
.then(() => {
const serializedVariables = JSON.stringify(this.queryVariables);
return this.$apollo.mutate({
mutation: reorderIssuesMutation,
variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables },
});
})
.catch((error) => {
createFlash({ message: this.$options.i18n.reorderError, captureError: true, error });
});
},
handleSort(sortKey) {
if (this.sortKey !== sortKey) {
this.pageParams = getInitialPageParams(sortKey);
}
this.sortKey = sortKey;
},
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
},
},
};
</script>
<template>
<div v-if="hasAnyIssues">
<issuable-list
:namespace="fullPath"
recent-searches-storage-key="issues"
:search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens"
:initial-filter-value="filterTokens"
:sort-options="sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
label-filter-param="label_name"
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:issuables-loading="$apollo.queries.issues.loading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
:use-keyset-pagination="true"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
:url-params="urlParams"
@click-tab="handleClickTab"
@filter="handleFilter"
@next-page="handleNextPage"
@previous-page="handlePreviousPage"
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
>
<template #nav-actions>
<gl-button
v-gl-tooltip
:href="rssPath"
icon="rss"
:title="$options.i18n.rssLabel"
:aria-label="$options.i18n.rssLabel"
/>
<gl-button
v-gl-tooltip
:href="calendarPath"
icon="calendar"
:title="$options.i18n.calendarLabel"
:aria-label="$options.i18n.calendarLabel"
/>
<csv-import-export-buttons
v-if="showCsvButtons"
class="gl-md-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
<gl-button
v-if="canBulkUpdate"
:disabled="isBulkEditButtonDisabled"
@click="handleBulkUpdateClick"
>
{{ $options.i18n.editIssues }}
</gl-button>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
<new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
<template #status="{ issuable = {} }">
{{ getStatus(issuable) }}
</template>
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="$options.i18n.relatedMergeRequests"
data-testid="merge-requests"
>
<gl-icon name="merge-request" />
{{ issuable.mergeRequestsCount }}
</li>
<li
v-if="issuable.upvotes"
v-gl-tooltip
class="issuable-upvotes gl-display-none gl-sm-display-block"
:title="$options.i18n.upvotes"
data-testid="issuable-upvotes"
>
<gl-icon name="thumb-up" />
{{ issuable.upvotes }}
</li>
<li
v-if="issuable.downvotes"
v-gl-tooltip
class="issuable-downvotes gl-display-none gl-sm-display-block"
:title="$options.i18n.downvotes"
data-testid="issuable-downvotes"
>
<gl-icon name="thumb-down" />
{{ issuable.downvotes }}
</li>
<blocking-issues-count
class="blocking-issues gl-display-none gl-sm-display-block"
:blocking-issues-count="issuable.blockingCount"
:is-list-item="true"
data-testid="blocking-issues"
/>
</template>
<template #empty-state>
<gl-empty-state
v-if="hasSearch"
:description="$options.i18n.noSearchResultsDescription"
:title="$options.i18n.noSearchResultsTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<gl-empty-state
v-else-if="isOpenTab"
:description="$options.i18n.noOpenIssuesDescription"
:title="$options.i18n.noOpenIssuesTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<gl-empty-state
v-else
:title="$options.i18n.noClosedIssuesTitle"
:svg-path="emptyStateSvgPath"
/>
</template>
</issuable-list>
<issuable-by-email v-if="initialEmail" class="gl-text-center gl-pt-5 gl-pb-7" />
</div>
<div v-else-if="isSignedIn">
<gl-empty-state
:description="$options.i18n.noIssuesSignedInDescription"
:title="$options.i18n.noIssuesSignedInTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
<csv-import-export-buttons
v-if="showCsvButtons"
class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
<new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
</gl-empty-state>
<hr />
<p class="gl-text-center gl-font-weight-bold gl-mb-0">
{{ $options.i18n.jiraIntegrationTitle }}
</p>
<p class="gl-text-center gl-mb-0">
<gl-sprintf :message="$options.i18n.jiraIntegrationMessage">
<template #jiraDocsLink="{ content }">
<gl-link :href="jiraIntegrationPath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<p class="gl-text-center gl-text-gray-500">
{{ $options.i18n.jiraIntegrationSecondaryMessage }}
</p>
</div>
<gl-empty-state
v-else
:description="$options.i18n.noIssuesSignedOutDescription"
:title="$options.i18n.noIssuesSignedOutTitle"
:svg-path="emptyStateSvgPath"
:primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
:primary-button-link="signInPath"
/>
</template>