Skip to content
Snippets Groups Projects
Commit 3a27f35e authored by Nicolò Mezzopera's avatar Nicolò Mezzopera
Browse files

Merge branch '322755-refactor-group-issues-page-to-vue' into 'master'

Refactor group issues page from Haml to Vue

See merge request gitlab-org/gitlab!68978
parents 2aed4648 50111d1d
No related branches found
No related tags found
No related merge requests found
Showing
with 268 additions and 98 deletions
Loading
Loading
@@ -69,6 +69,9 @@ export default {
isIssuableUrlExternal() {
return isExternal(this.webUrl);
},
reference() {
return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
},
labels() {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
Loading
Loading
@@ -201,9 +204,9 @@ export default {
</div>
<div class="issuable-info">
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference"
>{{ issuableSymbol }}{{ issuable.iid }}</span
>
<span v-else data-testid="issuable-reference" class="issuable-reference">
{{ reference }}
</span>
<span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
<span aria-hidden="true">&middot;</span>
<span
Loading
Loading
Loading
Loading
@@ -14,6 +14,7 @@ import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_coun
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';
Loading
Loading
@@ -140,11 +141,11 @@ export default {
initialEmail: {
default: '',
},
isSignedIn: {
isProject: {
default: false,
},
issuesPath: {
default: '',
isSignedIn: {
default: false,
},
jiraIntegrationPath: {
default: '',
Loading
Loading
@@ -186,9 +187,11 @@ export default {
variables() {
return this.queryVariables;
},
update: ({ project }) => project?.issues.nodes ?? [],
update(data) {
return data[this.namespace]?.issues.nodes ?? [];
},
result({ data }) {
this.pageInfo = data.project?.issues.pageInfo ?? {};
this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
Loading
Loading
@@ -204,7 +207,9 @@ export default {
variables() {
return this.queryVariables;
},
update: ({ project }) => project ?? {},
update(data) {
return data[this.namespace] ?? {};
},
error(error) {
createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error });
},
Loading
Loading
@@ -220,8 +225,9 @@ export default {
computed: {
queryVariables() {
return {
isSignedIn: this.isSignedIn,
fullPath: this.fullPath,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
search: this.searchQuery,
sort: this.sortKey,
state: this.state,
Loading
Loading
@@ -229,6 +235,9 @@ export default {
...this.apiFilterParams,
};
},
namespace() {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
},
Loading
Loading
@@ -242,7 +251,7 @@ export default {
return this.state === IssuableStates.Opened;
},
showCsvButtons() {
return this.isSignedIn;
return this.isProject && this.isSignedIn;
},
apiFilterParams() {
return convertToApiParams(this.filterTokens);
Loading
Loading
@@ -447,39 +456,41 @@ export default {
return this.$apollo
.query({
query: searchLabelsQuery,
variables: { fullPath: this.fullPath, search },
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
.then(({ data }) => data.project.labels.nodes);
.then(({ data }) => data[this.namespace]?.labels.nodes);
},
fetchMilestones(search) {
return this.$apollo
.query({
query: searchMilestonesQuery,
variables: { fullPath: this.fullPath, search },
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
.then(({ data }) => data.project.milestones.nodes);
.then(({ data }) => data[this.namespace]?.milestones.nodes);
},
fetchIterations(search) {
const id = Number(search);
const variables =
!search || Number.isNaN(id)
? { fullPath: this.fullPath, search }
: { fullPath: this.fullPath, 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.project.iterations.nodes);
.then(({ data }) => data[this.namespace]?.iterations.nodes);
},
fetchUsers(search) {
return this.$apollo
.query({
query: searchUsersQuery,
variables: { fullPath: this.fullPath, search },
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
.then(({ data }) => data.project.projectMembers.nodes.map((member) => member.user));
.then(({ data }) =>
data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user),
);
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
Loading
Loading
@@ -560,15 +571,16 @@ export default {
}
 
return axios
.put(joinPaths(this.issuesPath, issueToMove.iid, 'reorder'), {
.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, serializedVariables },
variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables },
});
})
.catch((error) => {
Loading
Loading
Loading
Loading
@@ -85,17 +85,17 @@ export function mountIssuesListApp() {
 
const resolvers = {
Mutation: {
reorderIssues: (_, { oldIndex, newIndex, serializedVariables }, { cache }) => {
reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
const variables = JSON.parse(serializedVariables);
const sourceData = cache.readQuery({ query: getIssuesQuery, variables });
 
const data = produce(sourceData, (draftData) => {
const issues = draftData.project.issues.nodes.slice();
const issues = draftData[namespace].issues.nodes.slice();
const issueToMove = issues[oldIndex];
issues.splice(oldIndex, 1);
issues.splice(newIndex, 0, issueToMove);
 
draftData.project.issues.nodes = issues;
draftData[namespace].issues.nodes = issues;
});
 
cache.writeQuery({ query: getIssuesQuery, variables, data });
Loading
Loading
@@ -128,8 +128,8 @@ export function mountIssuesListApp() {
hasMultipleIssueAssigneesFeature,
importCsvIssuesPath,
initialEmail,
isProject,
isSignedIn,
issuesPath,
jiraIntegrationPath,
markdownHelpPath,
maxAttachmentSize,
Loading
Loading
@@ -158,8 +158,8 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
isProject: parseBoolean(isProject),
isSignedIn: parseBoolean(isSignedIn),
issuesPath,
jiraIntegrationPath,
newIssuePath,
rssPath,
Loading
Loading
Loading
Loading
@@ -2,6 +2,7 @@
#import "./issue.fragment.graphql"
 
query getIssues(
$isProject: Boolean = false
$isSignedIn: Boolean = false
$fullPath: ID!
$search: String
Loading
Loading
@@ -20,7 +21,35 @@ query getIssues(
$firstPageSize: Int
$lastPageSize: Int
) {
project(fullPath: $fullPath) {
group(fullPath: $fullPath) @skip(if: $isProject) {
issues(
includeSubgroups: true
search: $search
sort: $sort
state: $state
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
pageInfo {
...PageInfo
}
nodes {
...IssueFragment
reference(full: true)
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
issues(
search: $search
sort: $sort
Loading
Loading
query getIssuesCount(
$isProject: Boolean = false
$fullPath: ID!
$search: String
$assigneeId: String
Loading
Loading
@@ -10,7 +11,54 @@ query getIssuesCount(
$types: [IssueType!]
$not: NegatedIssueFilterInput
) {
project(fullPath: $fullPath) {
group(fullPath: $fullPath) @skip(if: $isProject) {
openedIssues: issues(
includeSubgroups: true
state: opened
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
) {
count
}
closedIssues: issues(
includeSubgroups: true
state: closed
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
) {
count
}
allIssues: issues(
includeSubgroups: true
state: all
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
) {
count
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
openedIssues: issues(
state: opened
search: $search
Loading
Loading
Loading
Loading
@@ -13,6 +13,7 @@ fragment IssueFragment on Issue {
updatedAt
upvotes
userDiscussionsCount @include(if: $isSignedIn)
webPath
webUrl
assignees {
nodes {
Loading
Loading
fragment Iteration on Iteration {
id
title
}
fragment Label on Label {
id
color
textColor
title
}
fragment Milestone on Milestone {
id
title
}
mutation reorderIssues($oldIndex: Int, $newIndex: Int, $serializedVariables: String) {
mutation reorderIssues(
$oldIndex: Int
$newIndex: Int
$namespace: String
$serializedVariables: String
) {
reorderIssues(
oldIndex: $oldIndex
newIndex: $newIndex
namespace: $namespace
serializedVariables: $serializedVariables
) @client
}
query searchIterations($fullPath: ID!, $search: String, $id: ID) {
project(fullPath: $fullPath) {
iterations(title: $search, id: $id) {
#import "./iteration.fragment.graphql"
query searchIterations($fullPath: ID!, $search: String, $id: ID, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
iterations(title: $search, id: $id, includeAncestors: true) {
nodes {
id
title
...Iteration
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
iterations(title: $search, id: $id, includeAncestors: true) {
nodes {
...Iteration
}
}
}
Loading
Loading
query searchLabels($fullPath: ID!, $search: String) {
project(fullPath: $fullPath) {
#import "./label.fragment.graphql"
query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) {
nodes {
...Label
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
labels(searchTerm: $search, includeAncestorGroups: true) {
nodes {
id
color
textColor
title
...Label
}
}
}
Loading
Loading
query searchMilestones($fullPath: ID!, $search: String) {
project(fullPath: $fullPath) {
#import "./milestone.fragment.graphql"
query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) {
nodes {
...Milestone
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
milestones(searchTitle: $search, includeAncestors: true) {
nodes {
id
title
...Milestone
}
}
}
Loading
Loading
query searchUsers($fullPath: ID!, $search: String) {
project(fullPath: $fullPath) {
#import "./user.fragment.graphql"
query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) {
group(fullPath: $fullPath) @skip(if: $isProject) {
groupMembers(search: $search) {
nodes {
user {
...User
}
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
projectMembers(search: $search) {
nodes {
user {
id
avatarUrl
name
username
...User
}
}
}
Loading
Loading
fragment User on User {
id
avatarUrl
name
username
}
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
import { mountIssuablesListApp } from '~/issues_list';
import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
 
const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
if (gon.features?.vueIssuesList) {
mountIssuesListApp();
} else {
const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
 
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
 
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
useDefaultState: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
projectSelect();
initManualOrdering();
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
useDefaultState: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
projectSelect();
initManualOrdering();
 
if (gon.features?.vueIssuablesList) {
mountIssuablesListApp();
if (gon.features?.vueIssuablesList) {
mountIssuablesListApp();
}
}
Loading
Loading
@@ -33,6 +33,7 @@ class GroupsController < Groups::ApplicationController
 
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
push_frontend_feature_flag(:vue_issues_list, @group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml)
end
 
Loading
Loading
Loading
Loading
@@ -43,7 +43,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
end
 
Loading
Loading
Loading
Loading
@@ -203,34 +203,45 @@ def issue_header_actions_data(project, issuable, current_user)
}
end
 
def issues_list_data(project, current_user, finder)
def common_issues_list_data(namespace, current_user)
{
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
empty_state_svg_path: image_path('illustrations/issues.svg'),
full_path: namespace.full_path,
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
rss_path: url_for(safe_params.merge(rss_url_options)),
sign_in_path: new_user_session_path
}
end
def project_issues_list_data(project, current_user, finder)
common_issues_list_data(project, current_user).merge(
can_bulk_update: can?(current_user, :admin_issue, project).to_s,
can_edit: can?(current_user, :admin_project, project).to_s,
can_import_issues: can?(current_user, :import_issues, @project).to_s,
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: image_path('illustrations/issues.svg'),
export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path,
has_any_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path,
initial_email: project.new_issuable_address(current_user, 'issue'),
is_signed_in: current_user.present?.to_s,
issues_path: project_issues_path(project),
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
is_project: true.to_s,
markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project),
quick_actions_help_path: help_page_path('user/project/quick_actions'),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
rss_path: url_for(safe_params.merge(rss_url_options)),
show_new_issue_link: show_new_issue_link?(project).to_s,
sign_in_path: new_user_session_path
}
show_new_issue_link: show_new_issue_link?(project).to_s
)
end
def group_issues_list_data(group, current_user, issues)
common_issues_list_data(group, current_user).merge(
has_any_issues: issues.to_a.any?.to_s
)
end
 
# Overridden in EE
Loading
Loading
Loading
Loading
@@ -5,29 +5,34 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
 
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
= render 'shared/issuable/feed_buttons'
- if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml)
.js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) }
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- else
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
= render 'shared/issuable/feed_buttons'
 
- if @can_bulk_update
= render_if_exists 'shared/issuable/bulk_update_button', type: :issues
- if @can_bulk_update
= render_if_exists 'shared/issuable/bulk_update_button', type: :issues
 
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true
 
= render 'shared/issuable/search_bar', type: :issues
= render 'shared/issuable/search_bar', type: :issues
 
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
 
- if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any?
- if use_startup_call?
- add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
'can-bulk-edit': @can_bulk_update.to_json,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
'sort-key': @sort,
type: 'issues',
'scoped-labels-available': scoped_labels_available?(@group).to_json } }
- else
= render 'shared/issues', project_select_button: true
- if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any?
- if use_startup_call?
- add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
.js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
'can-bulk-edit': @can_bulk_update.to_json,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
'sort-key': @sort,
type: 'issues',
'scoped-labels-available': scoped_labels_available?(@group).to_json } }
- else
= render 'shared/issues', project_select_button: true
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment