Commit 3a27f35e authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

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
...@@ -69,6 +69,9 @@ export default { ...@@ -69,6 +69,9 @@ export default {
isIssuableUrlExternal() { isIssuableUrlExternal() {
return isExternal(this.webUrl); return isExternal(this.webUrl);
}, },
reference() {
return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
},
labels() { labels() {
return this.issuable.labels?.nodes || this.issuable.labels || []; return this.issuable.labels?.nodes || this.issuable.labels || [];
}, },
...@@ -201,9 +204,9 @@ export default { ...@@ -201,9 +204,9 @@ export default {
</div> </div>
<div class="issuable-info"> <div class="issuable-info">
<slot v-if="hasSlotContents('reference')" name="reference"></slot> <slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference" <span v-else data-testid="issuable-reference" class="issuable-reference">
>{{ issuableSymbol }}{{ issuable.iid }}</span {{ reference }}
> </span>
<span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3"> <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
<span aria-hidden="true">&middot;</span> <span aria-hidden="true">&middot;</span>
<span <span
......
...@@ -14,6 +14,7 @@ import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_coun ...@@ -14,6 +14,7 @@ import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_coun
import createFlash from '~/flash'; import createFlash from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants'; import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
...@@ -140,11 +141,11 @@ export default { ...@@ -140,11 +141,11 @@ export default {
initialEmail: { initialEmail: {
default: '', default: '',
}, },
isSignedIn: { isProject: {
default: false, default: false,
}, },
issuesPath: { isSignedIn: {
default: '', default: false,
}, },
jiraIntegrationPath: { jiraIntegrationPath: {
default: '', default: '',
...@@ -186,9 +187,11 @@ export default { ...@@ -186,9 +187,11 @@ export default {
variables() { variables() {
return this.queryVariables; return this.queryVariables;
}, },
update: ({ project }) => project?.issues.nodes ?? [], update(data) {
return data[this.namespace]?.issues.nodes ?? [];
},
result({ data }) { result({ data }) {
this.pageInfo = data.project?.issues.pageInfo ?? {}; this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
}, },
error(error) { error(error) {
...@@ -204,7 +207,9 @@ export default { ...@@ -204,7 +207,9 @@ export default {
variables() { variables() {
return this.queryVariables; return this.queryVariables;
}, },
update: ({ project }) => project ?? {}, update(data) {
return data[this.namespace] ?? {};
},
error(error) { error(error) {
createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error }); createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error });
}, },
...@@ -220,8 +225,9 @@ export default { ...@@ -220,8 +225,9 @@ export default {
computed: { computed: {
queryVariables() { queryVariables() {
return { return {
isSignedIn: this.isSignedIn,
fullPath: this.fullPath, fullPath: this.fullPath,
isProject: this.isProject,
isSignedIn: this.isSignedIn,
search: this.searchQuery, search: this.searchQuery,
sort: this.sortKey, sort: this.sortKey,
state: this.state, state: this.state,
...@@ -229,6 +235,9 @@ export default { ...@@ -229,6 +235,9 @@ export default {
...this.apiFilterParams, ...this.apiFilterParams,
}; };
}, },
namespace() {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
hasSearch() { hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length; return this.searchQuery || Object.keys(this.urlFilterParams).length;
}, },
...@@ -242,7 +251,7 @@ export default { ...@@ -242,7 +251,7 @@ export default {
return this.state === IssuableStates.Opened; return this.state === IssuableStates.Opened;
}, },
showCsvButtons() { showCsvButtons() {
return this.isSignedIn; return this.isProject && this.isSignedIn;
}, },
apiFilterParams() { apiFilterParams() {
return convertToApiParams(this.filterTokens); return convertToApiParams(this.filterTokens);
...@@ -447,39 +456,41 @@ export default { ...@@ -447,39 +456,41 @@ export default {
return this.$apollo return this.$apollo
.query({ .query({
query: searchLabelsQuery, 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) { fetchMilestones(search) {
return this.$apollo return this.$apollo
.query({ .query({
query: searchMilestonesQuery, 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) { fetchIterations(search) {
const id = Number(search); const id = Number(search);
const variables = const variables =
!search || Number.isNaN(id) !search || Number.isNaN(id)
? { fullPath: this.fullPath, search } ? { fullPath: this.fullPath, search, isProject: this.isProject }
: { fullPath: this.fullPath, id }; : { fullPath: this.fullPath, id, isProject: this.isProject };
return this.$apollo return this.$apollo
.query({ .query({
query: searchIterationsQuery, query: searchIterationsQuery,
variables, variables,
}) })
.then(({ data }) => data.project.iterations.nodes); .then(({ data }) => data[this.namespace]?.iterations.nodes);
}, },
fetchUsers(search) { fetchUsers(search) {
return this.$apollo return this.$apollo
.query({ .query({
query: searchUsersQuery, 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() { getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`; return `${this.exportCsvPath}${window.location.search}`;
...@@ -560,15 +571,16 @@ export default { ...@@ -560,15 +571,16 @@ export default {
} }
return axios return axios
.put(joinPaths(this.issuesPath, issueToMove.iid, 'reorder'), { .put(joinPaths(issueToMove.webPath, 'reorder'), {
move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId), move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId),
move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId), move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId),
group_full_path: this.isProject ? undefined : this.fullPath,
}) })
.then(() => { .then(() => {
const serializedVariables = JSON.stringify(this.queryVariables); const serializedVariables = JSON.stringify(this.queryVariables);
return this.$apollo.mutate({ return this.$apollo.mutate({
mutation: reorderIssuesMutation, mutation: reorderIssuesMutation,
variables: { oldIndex, newIndex, serializedVariables }, variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables },
}); });
}) })
.catch((error) => { .catch((error) => {
......
...@@ -85,17 +85,17 @@ export function mountIssuesListApp() { ...@@ -85,17 +85,17 @@ export function mountIssuesListApp() {
const resolvers = { const resolvers = {
Mutation: { Mutation: {
reorderIssues: (_, { oldIndex, newIndex, serializedVariables }, { cache }) => { reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
const variables = JSON.parse(serializedVariables); const variables = JSON.parse(serializedVariables);
const sourceData = cache.readQuery({ query: getIssuesQuery, variables }); const sourceData = cache.readQuery({ query: getIssuesQuery, variables });
const data = produce(sourceData, (draftData) => { const data = produce(sourceData, (draftData) => {
const issues = draftData.project.issues.nodes.slice(); const issues = draftData[namespace].issues.nodes.slice();
const issueToMove = issues[oldIndex]; const issueToMove = issues[oldIndex];
issues.splice(oldIndex, 1); issues.splice(oldIndex, 1);
issues.splice(newIndex, 0, issueToMove); issues.splice(newIndex, 0, issueToMove);
draftData.project.issues.nodes = issues; draftData[namespace].issues.nodes = issues;
}); });
cache.writeQuery({ query: getIssuesQuery, variables, data }); cache.writeQuery({ query: getIssuesQuery, variables, data });
...@@ -128,8 +128,8 @@ export function mountIssuesListApp() { ...@@ -128,8 +128,8 @@ export function mountIssuesListApp() {
hasMultipleIssueAssigneesFeature, hasMultipleIssueAssigneesFeature,
importCsvIssuesPath, importCsvIssuesPath,
initialEmail, initialEmail,
isProject,
isSignedIn, isSignedIn,
issuesPath,
jiraIntegrationPath, jiraIntegrationPath,
markdownHelpPath, markdownHelpPath,
maxAttachmentSize, maxAttachmentSize,
...@@ -158,8 +158,8 @@ export function mountIssuesListApp() { ...@@ -158,8 +158,8 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
isProject: parseBoolean(isProject),
isSignedIn: parseBoolean(isSignedIn), isSignedIn: parseBoolean(isSignedIn),
issuesPath,
jiraIntegrationPath, jiraIntegrationPath,
newIssuePath, newIssuePath,
rssPath, rssPath,
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
#import "./issue.fragment.graphql" #import "./issue.fragment.graphql"
query getIssues( query getIssues(
$isProject: Boolean = false
$isSignedIn: Boolean = false $isSignedIn: Boolean = false
$fullPath: ID! $fullPath: ID!
$search: String $search: String
...@@ -20,7 +21,35 @@ query getIssues( ...@@ -20,7 +21,35 @@ query getIssues(
$firstPageSize: Int $firstPageSize: Int
$lastPageSize: 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( issues(
search: $search search: $search
sort: $sort sort: $sort
......
query getIssuesCount( query getIssuesCount(
$isProject: Boolean = false
$fullPath: ID! $fullPath: ID!
$search: String $search: String
$assigneeId: String $assigneeId: String
...@@ -10,7 +11,54 @@ query getIssuesCount( ...@@ -10,7 +11,54 @@ query getIssuesCount(
$types: [IssueType!] $types: [IssueType!]
$not: NegatedIssueFilterInput $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( openedIssues: issues(
state: opened state: opened
search: $search search: $search
......
...@@ -13,6 +13,7 @@ fragment IssueFragment on Issue { ...@@ -13,6 +13,7 @@ fragment IssueFragment on Issue {
updatedAt updatedAt
upvotes upvotes
userDiscussionsCount @include(if: $isSignedIn) userDiscussionsCount @include(if: $isSignedIn)
webPath
webUrl webUrl
assignees { assignees {
nodes { nodes {
......
fragment Label on Label {
id
color
textColor
title
}
mutation reorderIssues($oldIndex: Int, $newIndex: Int, $serializedVariables: String) { mutation reorderIssues(
$oldIndex: Int
$newIndex: Int
$namespace: String
$serializedVariables: String
) {
reorderIssues( reorderIssues(
oldIndex: $oldIndex oldIndex: $oldIndex
newIndex: $newIndex newIndex: $newIndex
namespace: $namespace
serializedVariables: $serializedVariables serializedVariables: $serializedVariables
) @client ) @client
} }
query searchIterations($fullPath: ID!, $search: String, $id: ID) { #import "./iteration.fragment.graphql"
project(fullPath: $fullPath) {
iterations(title: $search, id: $id) { 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 { nodes {
id ...Iteration
title }
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
iterations(title: $search, id: $id, includeAncestors: true) {
nodes {
...Iteration
} }
} }
} }
......
query searchLabels($fullPath: ID!, $search: String) { #import "./label.fragment.graphql"
project(fullPath: $fullPath) {
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) { labels(searchTerm: $search, includeAncestorGroups: true) {
nodes { nodes {
id ...Label
color
textColor
title
} }
} }
} }
......
query searchMilestones($fullPath: ID!, $search: String) { #import "./milestone.fragment.graphql"
project(fullPath: $fullPath) {
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) { milestones(searchTitle: $search, includeAncestors: true) {
nodes { nodes {
id ...Milestone
title
} }
} }
} }
......
query searchUsers($fullPath: ID!, $search: String) { #import "./user.fragment.graphql"
project(fullPath: $fullPath) {
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) { projectMembers(search: $search) {
nodes { nodes {
user { user {
id ...User
avatarUrl
name
username
} }
} }
} }
......
fragment User on User {
id
avatarUrl
name
username
}
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; 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 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 initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select'; 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.addExtraTokensForIssues();
IssuableFilteredSearchTokenKeys.removeTokensForKeys('release'); IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.ISSUES, page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true, isGroupDecendent: true,
useDefaultState: true, useDefaultState: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
}); });
projectSelect(); projectSelect();
initManualOrdering(); initManualOrdering();
if (gon.features?.vueIssuablesList) { if (gon.features?.vueIssuablesList) {
mountIssuablesListApp(); mountIssuablesListApp();
}
} }
...@@ -33,6 +33,7 @@ class GroupsController < Groups::ApplicationController ...@@ -33,6 +33,7 @@ class GroupsController < Groups::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:vue_issuables_list, @group) 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) push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml)
end end
......
...@@ -43,7 +43,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -43,7 +43,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:tribute_autocomplete, @project) push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project) push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) 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) push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
end end
......
...@@ -203,34 +203,45 @@ module IssuesHelper ...@@ -203,34 +203,45 @@ module IssuesHelper
} }
end 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, autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)), 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_bulk_update: can?(current_user, :admin_issue, project).to_s,
can_edit: can?(current_user, :admin_project, project).to_s, can_edit: can?(current_user, :admin_project, project).to_s,
can_import_issues: can?(current_user, :import_issues, @project).to_s, can_import_issues: can?(current_user, :import_issues, @project).to_s,
email: current_user&.notification_email, email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), 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), export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path,
has_any_issues: project_issues(project).exists?.to_s, has_any_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path, import_csv_issues_path: import_csv_namespace_project_issues_path,
initial_email: project.new_issuable_address(current_user, 'issue'), initial_email: project.new_issuable_address(current_user, 'issue'),
is_signed_in: current_user.present?.to_s, is_project: true.to_s,
issues_path: project_issues_path(project),
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'), markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), 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) }), 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), project_import_jira_path: project_import_jira_path(project),
quick_actions_help_path: help_page_path('user/project/quick_actions'), quick_actions_help_path: help_page_path('user/project/quick_actions'),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), 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
show_new_issue_link: show_new_issue_link?(project).to_s, )
sign_in_path: new_user_session_path 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 end
# Overridden in EE # Overridden in EE
......
...@@ -5,29 +5,34 @@ ...@@ -5,29 +5,34 @@
= content_for :meta_tags do = content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
.top-area - if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml)
= render 'shared/issuable/nav', type: :issues .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) }
.nav-controls - if @can_bulk_update
= render 'shared/issuable/feed_buttons' = 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 - if @can_bulk_update
= render_if_exists 'shared/issuable/bulk_update_button', type: :issues = 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 - if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues = 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 Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any?
- if use_startup_call? - if use_startup_call?
- add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params)) - 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)), .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
'can-bulk-edit': @can_bulk_update.to_json, 'can-bulk-edit': @can_bulk_update.to_json,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') }, 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
'sort-key': @sort, 'sort-key': @sort,
type: 'issues', type: 'issues',
'scoped-labels-available': scoped_labels_available?(@group).to_json } } 'scoped-labels-available': scoped_labels_available?(@group).to_json } }
- else - else
= render 'shared/issues', project_select_button: true = render 'shared/issues', project_select_button: true
...@@ -13,8 +13,8 @@ ...@@ -13,8 +13,8 @@
issues_path: project_issues_path(@project), issues_path: project_issues_path(@project),
project_path: @project.full_path } } project_path: @project.full_path } }
- if Feature.enabled?(:vue_issues_list, @project) - if Feature.enabled?(:vue_issues_list, @project&.group, default_enabled: :yaml)
.js-issues-list{ data: issues_list_data(@project, current_user, finder) } .js-issues-list{ data: project_issues_list_data(@project, current_user, finder) }
- if @can_bulk_update - if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues = render 'shared/issuable/bulk_update_sidebar', type: :issues
- elsif project_issues(@project).exists? - elsif project_issues(@project).exists?
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
#import "~/issues_list/queries/issue.fragment.graphql" #import "~/issues_list/queries/issue.fragment.graphql"
query getIssues( query getIssues(
$isProject: Boolean = false
$isSignedIn: Boolean = false $isSignedIn: Boolean = false
$fullPath: ID! $fullPath: ID!
$search: String $search: String
...@@ -24,7 +25,42 @@ query getIssues( ...@@ -24,7 +25,42 @@ query getIssues(
$firstPageSize: Int $firstPageSize: Int
$lastPageSize: 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
epicId: $epicId
iterationId: $iterationId
iterationWildcardId: $iterationWildcardId
weight: $weight
not: $not
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
pageInfo {
...PageInfo
}
nodes {
...IssueFragment
reference(full: true)
blockingCount
healthStatus
weight
}
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
issues( issues(
search: $search search: $search
sort: $sort sort: $sort
......
query getIssuesCount( query getIssuesCount(
$isProject: Boolean = false
$fullPath: ID! $fullPath: ID!
$search: String $search: String
$assigneeId: String $assigneeId: String
...@@ -14,7 +15,66 @@ query getIssuesCount( ...@@ -14,7 +15,66 @@ query getIssuesCount(
$weight: String $weight: String
$not: NegatedIssueFilterInput $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
epicId: $epicId
iterationId: $iterationId
iterationWildcardId: $iterationWildcardId
weight: $weight
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
epicId: $epicId
iterationId: $iterationId
iterationWildcardId: $iterationWildcardId
weight: $weight
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
epicId: $epicId
iterationId: $iterationId
iterationWildcardId: $iterationWildcardId
weight: $weight
not: $not
) {
count
}
}
project(fullPath: $fullPath) @include(if: $isProject) {
openedIssues: issues( openedIssues: issues(
state: opened state: opened
search: $search search: $search
......
...@@ -42,21 +42,35 @@ module EE ...@@ -42,21 +42,35 @@ module EE
actions actions
end end
override :issues_list_data override :common_issues_list_data
def issues_list_data(project, current_user, finder) def common_issues_list_data(namespace, current_user)
data = super.merge!( super.merge(
has_blocked_issues_feature: project.feature_available?(:blocked_issues).to_s, has_blocked_issues_feature: namespace.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: project.feature_available?(:issuable_health_status).to_s, has_issuable_health_status_feature: namespace.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: project.feature_available?(:issue_weights).to_s, has_issue_weights_feature: namespace.feature_available?(:issue_weights).to_s,
has_iterations_feature: project.feature_available?(:iterations).to_s, has_iterations_feature: namespace.feature_available?(:iterations).to_s,
has_multiple_issue_assignees_feature: project.feature_available?(:multiple_issue_assignees).to_s has_multiple_issue_assignees_feature: namespace.feature_available?(:multiple_issue_assignees).to_s
) )
end
if project.feature_available?(:epics) && project.group override :project_issues_list_data
data[:group_epics_path] = group_epics_path(project.group, format: :json) def project_issues_list_data(project, current_user, finder)
super.tap do |data|
if project.feature_available?(:epics) && project.group
data[:group_epics_path] = group_epics_path(project.group, format: :json)
end
end end
end
data override :group_issues_list_data
def group_issues_list_data(group, current_user, issues)
super.tap do |data|
data[:can_bulk_update] = (can?(current_user, :admin_issue, group) && group.feature_available?(:group_bulk_edit)).to_s
if group.feature_available?(:epics)
data[:group_epics_path] = group_epics_path(group, format: :json)
end
end
end end
end end
end end
...@@ -124,7 +124,7 @@ RSpec.describe EE::IssuesHelper do ...@@ -124,7 +124,7 @@ RSpec.describe EE::IssuesHelper do
end end
end end
describe '#issues_list_data' do describe '#project_issues_list_data' do
let(:current_user) { double.as_null_object } let(:current_user) { double.as_null_object }
let(:finder) { double.as_null_object } let(:finder) { double.as_null_object }
...@@ -150,14 +150,14 @@ RSpec.describe EE::IssuesHelper do ...@@ -150,14 +150,14 @@ RSpec.describe EE::IssuesHelper do
group_epics_path: group_epics_path(project.group, format: :json) group_epics_path: group_epics_path(project.group, format: :json)
} }
expect(helper.issues_list_data(project, current_user, finder)).to include(expected) expect(helper.project_issues_list_data(project, current_user, finder)).to include(expected)
end end
context 'when project does not have group' do context 'when project does not have group' do
let(:project_with_no_group) { create :project } let(:project_with_no_group) { create :project }
it 'does not return group_epics_path' do it 'does not return group_epics_path' do
expect(helper.issues_list_data(project_with_no_group, current_user, finder)).not_to include(:group_epics_path) expect(helper.project_issues_list_data(project_with_no_group, current_user, finder)).not_to include(:group_epics_path)
end end
end end
end end
...@@ -176,7 +176,60 @@ RSpec.describe EE::IssuesHelper do ...@@ -176,7 +176,60 @@ RSpec.describe EE::IssuesHelper do
has_multiple_issue_assignees_feature: 'false' has_multiple_issue_assignees_feature: 'false'
} }
result = helper.issues_list_data(project, current_user, finder) result = helper.project_issues_list_data(project, current_user, finder)
expect(result).to include(expected)
expect(result).not_to include(:group_epics_path)
end
end
end
describe '#group_issues_list_data' do
let(:current_user) { double.as_null_object }
let(:issues) { [] }
before do
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:url_for).and_return('#')
end
context 'when features are enabled' do
before do
stub_licensed_features(blocked_issues: true, epics: true, group_bulk_edit: true, issuable_health_status: true, issue_weights: true, iterations: true, multiple_issue_assignees: true)
end
it 'returns data with licensed features enabled' do
expected = {
can_bulk_update: 'true',
has_blocked_issues_feature: 'true',
has_issuable_health_status_feature: 'true',
has_issue_weights_feature: 'true',
has_iterations_feature: 'true',
has_multiple_issue_assignees_feature: 'true',
group_epics_path: group_epics_path(project.group, format: :json)
}
expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected)
end
end
context 'when features are disabled' do
before do
stub_licensed_features(blocked_issues: false, epics: false, group_bulk_edit: false, issuable_health_status: false, issue_weights: false, iterations: false, multiple_issue_assignees: false)
end
it 'returns data with licensed features disabled' do
expected = {
can_bulk_update: 'false',
has_blocked_issues_feature: 'false',
has_issuable_health_status_feature: 'false',
has_issue_weights_feature: 'false',
has_iterations_feature: 'false',
has_multiple_issue_assignees_feature: 'false'
}
result = helper.group_issues_list_data(group, current_user, issues)
expect(result).to include(expected) expect(result).to include(expected)
expect(result).not_to include(:group_epics_path) expect(result).not_to include(:group_epics_path)
......
...@@ -68,8 +68,8 @@ describe('IssuesListApp component', () => { ...@@ -68,8 +68,8 @@ describe('IssuesListApp component', () => {
hasBlockedIssuesFeature: true, hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true, hasIssueWeightsFeature: true,
hasIterationsFeature: true, hasIterationsFeature: true,
isProject: true,
isSignedIn: true, isSignedIn: true,
issuesPath: 'path/to/issues',
jiraIntegrationPath: 'jira/integration/path', jiraIntegrationPath: 'jira/integration/path',
newIssuePath: 'new/issue/path', newIssuePath: 'new/issue/path',
rssPath: 'rss/path', rssPath: 'rss/path',
...@@ -191,7 +191,7 @@ describe('IssuesListApp component', () => { ...@@ -191,7 +191,7 @@ describe('IssuesListApp component', () => {
setWindowLocation(search); setWindowLocation(search);
wrapper = mountComponent({ wrapper = mountComponent({
provide: { ...defaultProvide, isSignedIn: true }, provide: { isSignedIn: true },
mountFn: mount, mountFn: mount,
}); });
...@@ -208,7 +208,15 @@ describe('IssuesListApp component', () => { ...@@ -208,7 +208,15 @@ describe('IssuesListApp component', () => {
describe('when user is not signed in', () => { describe('when user is not signed in', () => {
it('does not render', () => { it('does not render', () => {
wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: false } }); wrapper = mountComponent({ provide: { isSignedIn: false } });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
});
describe('when in a group context', () => {
it('does not render', () => {
wrapper = mountComponent({ provide: { isProject: false } });
expect(findCsvImportExportButtons().exists()).toBe(false); expect(findCsvImportExportButtons().exists()).toBe(false);
}); });
...@@ -625,72 +633,89 @@ describe('IssuesListApp component', () => { ...@@ -625,72 +633,89 @@ describe('IssuesListApp component', () => {
...defaultQueryResponse.data.project.issues.nodes[0], ...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/1', id: 'gid://gitlab/Issue/1',
iid: '101', iid: '101',
title: 'Issue one', reference: 'group/project#1',
webPath: '/group/project/-/issues/1',
}; };
const issueTwo = { const issueTwo = {
...defaultQueryResponse.data.project.issues.nodes[0], ...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/2', id: 'gid://gitlab/Issue/2',
iid: '102', iid: '102',
title: 'Issue two', reference: 'group/project#2',
webPath: '/group/project/-/issues/2',
}; };
const issueThree = { const issueThree = {
...defaultQueryResponse.data.project.issues.nodes[0], ...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/3', id: 'gid://gitlab/Issue/3',
iid: '103', iid: '103',
title: 'Issue three', reference: 'group/project#3',
webPath: '/group/project/-/issues/3',
}; };
const issueFour = { const issueFour = {
...defaultQueryResponse.data.project.issues.nodes[0], ...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/4', id: 'gid://gitlab/Issue/4',
iid: '104', iid: '104',
title: 'Issue four', reference: 'group/project#4',
webPath: '/group/project/-/issues/4',
}; };
const response = { const response = (isProject = true) => ({
data: { data: {
project: { [isProject ? 'project' : 'group']: {
issues: { issues: {
...defaultQueryResponse.data.project.issues, ...defaultQueryResponse.data.project.issues,
nodes: [issueOne, issueTwo, issueThree, issueFour], nodes: [issueOne, issueTwo, issueThree, issueFour],
}, },
}, },
}, },
};
beforeEach(() => {
wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response) });
jest.runOnlyPendingTimers();
}); });
describe('when successful', () => { describe('when successful', () => {
describe.each` describe.each([true, false])('when isProject=%s', (isProject) => {
description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId describe.each`
${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
`( ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
'when moving issue $description', `(
({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { 'when moving issue $description',
it('makes API call to reorder the issue', async () => { ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); beforeEach(() => {
wrapper = mountComponent({
await waitForPromises(); provide: { isProject },
issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)),
expect(axiosMock.history.put[0]).toMatchObject({ });
url: joinPaths(defaultProvide.issuesPath, issueToMove.iid, 'reorder'), jest.runOnlyPendingTimers();
data: JSON.stringify({
move_before_id: getIdFromGraphQLId(moveBeforeId),
move_after_id: getIdFromGraphQLId(moveAfterId),
}),
}); });
});
}, it('makes API call to reorder the issue', async () => {
); findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
await waitForPromises();
expect(axiosMock.history.put[0]).toMatchObject({
url: joinPaths(issueToMove.webPath, 'reorder'),
data: JSON.stringify({
move_before_id: getIdFromGraphQLId(moveBeforeId),
move_after_id: getIdFromGraphQLId(moveAfterId),
group_full_path: isProject ? undefined : defaultProvide.fullPath,
}),
});
});
},
);
});
}); });
describe('when unsuccessful', () => { describe('when unsuccessful', () => {
beforeEach(() => {
wrapper = mountComponent({
issuesQueryResponse: jest.fn().mockResolvedValue(response()),
});
jest.runOnlyPendingTimers();
});
it('displays an error message', async () => { it('displays an error message', async () => {
axiosMock.onPut(joinPaths(defaultProvide.issuesPath, issueOne.iid, 'reorder')).reply(500); axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500);
findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
......
...@@ -29,6 +29,7 @@ export const getIssuesQueryResponse = { ...@@ -29,6 +29,7 @@ export const getIssuesQueryResponse = {
updatedAt: '2021-05-22T04:08:01Z', updatedAt: '2021-05-22T04:08:01Z',
upvotes: 3, upvotes: 3,
userDiscussionsCount: 4, userDiscussionsCount: 4,
webPath: 'project/-/issues/789',
webUrl: 'project/-/issues/789', webUrl: 'project/-/issues/789',
assignees: { assignees: {
nodes: [ nodes: [
......
...@@ -318,8 +318,8 @@ RSpec.describe IssuesHelper do ...@@ -318,8 +318,8 @@ RSpec.describe IssuesHelper do
has_any_issues: project_issues(project).exists?.to_s, has_any_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#', import_csv_issues_path: '#',
initial_email: project.new_issuable_address(current_user, 'issue'), initial_email: project.new_issuable_address(current_user, 'issue'),
is_project: 'true',
is_signed_in: current_user.present?.to_s, 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'), jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'), markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
...@@ -332,11 +332,11 @@ RSpec.describe IssuesHelper do ...@@ -332,11 +332,11 @@ RSpec.describe IssuesHelper do
sign_in_path: new_user_session_path sign_in_path: new_user_session_path
} }
expect(helper.issues_list_data(project, current_user, finder)).to include(expected) expect(helper.project_issues_list_data(project, current_user, finder)).to include(expected)
end end
end end
describe '#issues_list_data' do describe '#project_issues_list_data' do
context 'when user is signed in' do context 'when user is signed in' do
it_behaves_like 'issues list data' do it_behaves_like 'issues list data' do
let(:current_user) { double.as_null_object } let(:current_user) { double.as_null_object }
...@@ -350,6 +350,33 @@ RSpec.describe IssuesHelper do ...@@ -350,6 +350,33 @@ RSpec.describe IssuesHelper do
end end
end end
describe '#group_issues_list_data' do
let(:group) { create(:group) }
let(:current_user) { double.as_null_object }
let(:issues) { [] }
it 'returns expected result' do
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:can?).and_return(true)
allow(helper).to receive(:image_path).and_return('#')
allow(helper).to receive(:url_for).and_return('#')
expected = {
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: '#',
empty_state_svg_path: '#',
full_path: group.full_path,
has_any_issues: issues.to_a.any?.to_s,
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
rss_path: '#',
sign_in_path: new_user_session_path
}
expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected)
end
end
describe '#issue_manual_ordering_class' do describe '#issue_manual_ordering_class' do
context 'when sorting by relative position' do context 'when sorting by relative position' do
before do before do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment