diff --git a/.gitlab/ci/static-analysis.gitlab-ci.yml b/.gitlab/ci/static-analysis.gitlab-ci.yml
index 3d030026e90..b351a63ecf0 100644
--- a/.gitlab/ci/static-analysis.gitlab-ci.yml
+++ b/.gitlab/ci/static-analysis.gitlab-ci.yml
@@ -211,7 +211,7 @@ ping-appsec-for-sast-findings:
- .ping-appsec-for-sast-findings:rules
variables:
# Project Access Token bot ID for /gitlab-com/gl-security/appsec/sast-custom-rules
- BOT_USER_ID: 13559989
+ BOT_USER_ID: 14406065
needs:
- semgrep-appsec-custom-rules
script:
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 93bd97e691b..bf77aa4996c 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -5,7 +5,7 @@ import {
TYPENAME_MILESTONE,
TYPENAME_USER,
} from '~/graphql_shared/constants';
-import { isGid, convertToGraphQLId } from '~/graphql_shared/utils';
+import { isGid, convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
ListType,
MilestoneIDs,
@@ -202,6 +202,38 @@ export function moveItemListHelper(item, fromList, toList) {
return updatedItem;
}
+export function moveItemVariables({
+ iid,
+ epicId,
+ fromListId,
+ toListId,
+ moveBeforeId,
+ moveAfterId,
+ isIssue,
+ boardId,
+ itemToMove,
+}) {
+ if (isIssue) {
+ return {
+ iid,
+ boardId,
+ projectPath: itemToMove.referencePath.split(/[#]/)[0],
+ moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined,
+ moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined,
+ fromListId: getIdFromGraphQLId(fromListId),
+ toListId: getIdFromGraphQLId(toListId),
+ };
+ }
+ return {
+ epicId,
+ boardId,
+ moveBeforeId,
+ moveAfterId,
+ fromListId,
+ toListId,
+ };
+}
+
export function isListDraggable(list) {
return list.listType !== ListType.backlog && list.listType !== ListType.closed;
}
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 5f082066ad4..218711346b0 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -9,6 +9,7 @@ import { sortableStart, sortableEnd } from '~/sortable/utils';
import Tracking from '~/tracking';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
DEFAULT_BOARD_LIST_ITEMS_SIZE,
toggleFormEventPrefix,
@@ -16,6 +17,13 @@ import {
listIssuablesQueries,
ListType,
} from 'ee_else_ce/boards/constants';
+import {
+ addItemToList,
+ removeItemFromList,
+ updateEpicsCount,
+ updateIssueCountAndWeight,
+} from '../graphql/cache_updates';
+import { shouldCloneCard, moveItemVariables } from '../boards_util';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue';
@@ -37,7 +45,7 @@ export default {
GlIntersectionObserver,
BoardCardMoveToPosition,
},
- mixins: [Tracking.mixin()],
+ mixins: [Tracking.mixin(), glFeatureFlagMixin()],
inject: [
'isEpicBoard',
'isGroupBoard',
@@ -73,6 +81,8 @@ export default {
showEpicForm: false,
currentList: null,
isLoadingMore: false,
+ toListId: null,
+ toList: {},
};
},
apollo: {
@@ -111,6 +121,29 @@ export default {
isSingleRequest: true,
},
},
+ toList: {
+ query() {
+ return listIssuablesQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ id: this.toListId,
+ ...this.listQueryVariables,
+ };
+ },
+ skip() {
+ return !this.toListId;
+ },
+ update(data) {
+ return data[this.boardType].board.lists.nodes[0];
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ error() {
+ // handle error
+ },
+ },
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags', 'isUpdateIssueOrderInProgress']),
@@ -205,6 +238,9 @@ export default {
showMoveToPosition() {
return !this.disabled && this.list.listType !== ListType.closed;
},
+ shouldCloneCard() {
+ return shouldCloneCard(this.list.listType, this.toList.listType);
+ },
},
watch: {
boardListItems() {
@@ -337,15 +373,123 @@ export default {
}
}
- this.moveItem({
- itemId,
- itemIid,
- itemPath,
- fromListId: from.dataset.listId,
- toListId: to.dataset.listId,
- moveBeforeId,
- moveAfterId,
+ if (this.isApolloBoard) {
+ this.moveBoardItem(
+ {
+ epicId: itemId,
+ iid: itemIid,
+ fromListId: from.dataset.listId,
+ toListId: to.dataset.listId,
+ moveBeforeId,
+ moveAfterId,
+ },
+ newIndex,
+ );
+ } else {
+ this.moveItem({
+ itemId,
+ itemIid,
+ itemPath,
+ fromListId: from.dataset.listId,
+ toListId: to.dataset.listId,
+ moveBeforeId,
+ moveAfterId,
+ });
+ }
+ },
+ isItemInTheList(itemIid) {
+ const items = this.toList?.[`${this.issuableType}s`]?.nodes || [];
+ return items.some((item) => item.iid === itemIid);
+ },
+ async moveBoardItem(variables, newIndex) {
+ const { fromListId, toListId, iid } = variables;
+ this.toListId = toListId;
+ await this.$nextTick(); // we need this next tick to retrieve `toList` from Apollo cache
+
+ const itemToMove = this.boardListItems.find((item) => item.iid === iid);
+
+ if (this.shouldCloneCard && this.isItemInTheList(iid)) {
+ return;
+ }
+
+ try {
+ await this.$apollo.mutate({
+ mutation: listIssuablesQueries[this.issuableType].moveMutation,
+ variables: {
+ ...moveItemVariables({
+ ...variables,
+ isIssue: !this.isEpicBoard,
+ boardId: this.boardId,
+ itemToMove,
+ }),
+ withColor: this.isEpicBoard && this.glFeatures.epicColorHighlight,
+ },
+ update: (cache, { data: { issuableMoveList } }) =>
+ this.updateCacheAfterMovingItem({
+ issuableMoveList,
+ fromListId,
+ toListId,
+ newIndex,
+ cache,
+ }),
+ optimisticResponse: {
+ issuableMoveList: {
+ issuable: itemToMove,
+ errors: [],
+ },
+ },
+ });
+ } catch {
+ // handle error
+ }
+ },
+ updateCacheAfterMovingItem({ issuableMoveList, fromListId, toListId, newIndex, cache }) {
+ const { issuable } = issuableMoveList;
+ if (!this.shouldCloneCard) {
+ removeItemFromList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: fromListId },
+ boardType: this.boardType,
+ id: issuable.id,
+ issuableType: this.issuableType,
+ cache,
+ });
+ }
+
+ addItemToList({
+ query: listIssuablesQueries[this.issuableType].query,
+ variables: { ...this.listQueryVariables, id: toListId },
+ issuable,
+ newIndex,
+ boardType: this.boardType,
+ issuableType: this.issuableType,
+ cache,
});
+
+ this.updateCountAndWeight({ fromListId, toListId, issuable, cache });
+ },
+ updateCountAndWeight({ fromListId, toListId, issuable, isAddingIssue, cache }) {
+ if (!this.isEpicBoard) {
+ updateIssueCountAndWeight({
+ fromListId,
+ toListId,
+ filterParams: this.filterParams,
+ issuable,
+ shouldClone: isAddingIssue || this.shouldCloneCard,
+ cache,
+ });
+ } else {
+ const { issuableType, filterParams } = this;
+ updateEpicsCount({
+ issuableType,
+ toListId,
+ fromListId,
+ filterParams,
+ issuable,
+ shouldClone: this.shouldCloneCard,
+ cache,
+ });
+ }
},
},
};
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index ca188c741a9..d4d1bc7804e 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -10,9 +10,11 @@ import updateBoardListMutation from './graphql/board_list_update.mutation.graphq
import toggleListCollapsedMutation from './graphql/client/board_toggle_collapsed.mutation.graphql';
import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
+import issueMoveListMutation from './graphql/issue_move_list.mutation.graphql';
import groupBoardQuery from './graphql/group_board.query.graphql';
import projectBoardQuery from './graphql/project_board.query.graphql';
import listIssuesQuery from './graphql/lists_issues.query.graphql';
+import listDeferredQuery from './graphql/board_lists_deferred.query.graphql';
export const BoardType = {
project: 'project',
@@ -72,6 +74,12 @@ export const listsQuery = {
},
};
+export const listsDeferredQuery = {
+ [TYPE_ISSUE]: {
+ query: listDeferredQuery,
+ },
+};
+
export const createListMutations = {
[TYPE_ISSUE]: {
mutation: createBoardListMutation,
@@ -117,6 +125,7 @@ export const subscriptionQueries = {
export const listIssuablesQueries = {
[TYPE_ISSUE]: {
query: listIssuesQuery,
+ moveMutation: issueMoveListMutation,
},
};
diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js
new file mode 100644
index 00000000000..084809e4e60
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/cache_updates.js
@@ -0,0 +1,118 @@
+import produce from 'immer';
+import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import { listsDeferredQuery } from 'ee_else_ce/boards/constants';
+
+export function removeItemFromList({ query, variables, boardType, id, issuableType, cache }) {
+ cache.updateQuery({ query, variables }, (sourceData) =>
+ produce(sourceData, (draftData) => {
+ const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`];
+ items.splice(
+ items.findIndex((item) => item.id === id),
+ 1,
+ );
+ }),
+ );
+}
+
+export function addItemToList({
+ query,
+ variables,
+ boardType,
+ issuable,
+ newIndex,
+ issuableType,
+ cache,
+}) {
+ cache.updateQuery({ query, variables }, (sourceData) =>
+ produce(sourceData, (draftData) => {
+ const { nodes: items } = draftData[boardType].board.lists.nodes[0][`${issuableType}s`];
+ items.splice(newIndex, 0, issuable);
+ }),
+ );
+}
+
+export function updateIssueCountAndWeight({
+ fromListId,
+ toListId,
+ filterParams,
+ issuable: issue,
+ shouldClone,
+ cache,
+}) {
+ if (!shouldClone) {
+ cache.updateQuery(
+ {
+ query: listQuery,
+ variables: { id: fromListId, filters: filterParams },
+ },
+ ({ boardList }) => ({
+ boardList: {
+ ...boardList,
+ issuesCount: boardList.issuesCount - 1,
+ totalWeight: boardList.totalWeight - issue.weight,
+ },
+ }),
+ );
+ }
+
+ cache.updateQuery(
+ {
+ query: listQuery,
+ variables: { id: toListId, filters: filterParams },
+ },
+ ({ boardList }) => ({
+ boardList: {
+ ...boardList,
+ issuesCount: boardList.issuesCount + 1,
+ totalWeight: boardList.totalWeight + issue.weight,
+ },
+ }),
+ );
+}
+
+export function updateEpicsCount({
+ issuableType,
+ filterParams,
+ fromListId,
+ toListId,
+ issuable: epic,
+ shouldClone,
+ cache,
+}) {
+ const epicWeight = epic.descendantWeightSum.openedIssues + epic.descendantWeightSum.closedIssues;
+ if (!shouldClone) {
+ cache.updateQuery(
+ {
+ query: listsDeferredQuery[issuableType].query,
+ variables: { id: fromListId, filters: filterParams },
+ },
+ ({ epicBoardList }) => ({
+ epicBoardList: {
+ ...epicBoardList,
+ metadata: {
+ epicsCount: epicBoardList.metadata.epicsCount - 1,
+ totalWeight: epicBoardList.metadata.totalWeight - epicWeight,
+ ...epicBoardList.metadata,
+ },
+ },
+ }),
+ );
+ }
+
+ cache.updateQuery(
+ {
+ query: listsDeferredQuery[issuableType].query,
+ variables: { id: toListId, filters: filterParams },
+ },
+ ({ epicBoardList }) => ({
+ epicBoardList: {
+ ...epicBoardList,
+ metadata: {
+ epicsCount: epicBoardList.metadata.epicsCount + 1,
+ totalWeight: epicBoardList.metadata.totalWeight + epicWeight,
+ ...epicBoardList.metadata,
+ },
+ },
+ }),
+ );
+}
diff --git a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
index 89670760450..4a46d741a78 100644
--- a/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
@@ -9,7 +9,7 @@ mutation issueMoveList(
$moveBeforeId: ID
$moveAfterId: ID
) {
- issueMoveList(
+ issuableMoveList: issueMoveList(
input: {
projectPath: $projectPath
iid: $iid
@@ -20,7 +20,7 @@ mutation issueMoveList(
moveAfterId: $moveAfterId
}
) {
- issue {
+ issuable: issue {
...Issue
}
errors
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index a144054d680..d96d92948be 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -602,8 +602,8 @@ export default {
cache,
{
data: {
- issueMoveList: {
- issue: { weight },
+ issuableMoveList: {
+ issuable: { weight },
},
},
},
@@ -661,11 +661,11 @@ export default {
},
});
- if (data?.issueMoveList?.errors.length || !data.issueMoveList) {
+ if (data?.issuableMoveList?.errors.length || !data.issuableMoveList) {
throw new Error('issueMoveList empty');
}
- commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue });
+ commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issuableMoveList.issuable });
commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
} catch {
commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue
index 63169b790c7..6072d923b5f 100644
--- a/app/assets/javascripts/environments/components/environment_delete.vue
+++ b/app/assets/javascripts/environments/components/environment_delete.vue
@@ -4,14 +4,14 @@
* Used in the environments table.
*/
-import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import setEnvironmentToDelete from '../graphql/mutations/set_environment_to_delete.mutation.graphql';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
directives: {
GlModalDirective,
@@ -30,11 +30,15 @@ export default {
data() {
return {
isLoading: false,
+ item: {
+ text: s__('Environments|Delete environment'),
+ extraAttrs: {
+ variant: 'danger',
+ class: 'gl-text-red-500!',
+ },
+ },
};
},
- i18n: {
- title: s__('Environments|Delete environment'),
- },
mounted() {
if (!this.graphql) {
eventHub.$on('deleteEnvironment', this.onDeleteEnvironment);
@@ -65,12 +69,10 @@ export default {
};
-
- {{ $options.i18n.title }}
-
+ @action="onClick"
+ />
diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue
index f5a83b97552..1a63bfa2c1c 100644
--- a/app/assets/javascripts/environments/components/environment_pin.vue
+++ b/app/assets/javascripts/environments/components/environment_pin.vue
@@ -3,14 +3,14 @@
* Renders a prevent auto-stop button.
* Used in environments table.
*/
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
import cancelAutoStopMutation from '../graphql/mutations/cancel_auto_stop.mutation.graphql';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
autoStopUrl: {
@@ -23,6 +23,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ item: { text: __('Prevent auto-stopping') },
+ };
+ },
methods: {
onPinClick() {
if (this.graphql) {
@@ -35,11 +40,8 @@ export default {
}
},
},
- title: __('Prevent auto-stopping'),
};
-
- {{ $options.title }}
-
+
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index f7a853f3128..291d8558a74 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -5,14 +5,14 @@
*
* Makes a post request when the button is clicked.
*/
-import { GlModalDirective, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import setEnvironmentToRollback from '../graphql/mutations/set_environment_to_rollback.mutation.graphql';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
directives: {
GlModal: GlModalDirective,
@@ -41,12 +41,14 @@ export default {
},
},
- computed: {
- title() {
- return this.isLastDeployment
- ? s__('Environments|Re-deploy to environment')
- : s__('Environments|Rollback environment');
- },
+ data() {
+ return {
+ item: {
+ text: this.isLastDeployment
+ ? s__('Environments|Re-deploy to environment')
+ : s__('Environments|Rollback environment'),
+ },
+ };
},
methods: {
@@ -71,7 +73,5 @@ export default {
};
-
- {{ title }}
-
+
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 0df07f0457f..1c4209397b1 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -3,12 +3,12 @@
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
- GlDropdownItem,
+ GlDisclosureDropdownItem,
},
props: {
terminalPath: {
@@ -22,11 +22,13 @@ export default {
default: false,
},
},
- title: __('Terminal'),
+ data() {
+ return {
+ item: { text: __('Terminal'), href: this.terminalPath },
+ };
+ },
};
-
- {{ $options.title }}
-
+
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 72323c0e43e..1f3d429cc3e 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -1,9 +1,9 @@