diff --git a/GITLAB_PAGES_VERSION b/GITLAB_PAGES_VERSION
index bb120e876c6..4d5fde5bd16 100644
--- a/GITLAB_PAGES_VERSION
+++ b/GITLAB_PAGES_VERSION
@@ -1 +1 @@
-1.59.0
+1.60.0
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
index 92928ca429f..afe621ac3c5 100644
--- a/app/assets/javascripts/design_management/constants.js
+++ b/app/assets/javascripts/design_management/constants.js
@@ -12,3 +12,5 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
};
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
+
+export const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index f81d4f6662f..51983b19677 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -4,16 +4,15 @@ import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import VueDraggable from 'vuedraggable';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
-import createFlash, { FLASH_TYPES } from '~/flash';
import { getFilename, validateImageName } from '~/lib/utils/file_upload';
-import { __, s__, sprintf } from '~/locale';
+import { __, s__ } from '~/locale';
import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import DeleteButton from '../components/delete_button.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import Design from '../components/list/item.vue';
import UploadButton from '../components/upload/button.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
-import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
+import { MAXIMUM_FILE_UPLOAD_LIMIT, VALID_DESIGN_FILE_MIMETYPE } from '../constants';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import allDesignsMixin from '../mixins/all_designs';
@@ -35,11 +34,10 @@ import {
UPLOAD_DESIGN_INVALID_FILETYPE_ERROR,
designUploadSkippedWarning,
designDeletionError,
+ MAXIMUM_FILE_UPLOAD_LIMIT_REACHED,
} from '../utils/error_messages';
import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
-const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
-
export default {
components: {
GlLoadingIcon,
@@ -87,6 +85,7 @@ export default {
isDraggingDesign: false,
reorderedDesigns: null,
isReorderingInProgress: false,
+ uploadError: null,
};
},
computed: {
@@ -159,16 +158,7 @@ export default {
if (!this.canCreateDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
- createFlash({
- message: sprintf(
- s__(
- 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
- ),
- {
- upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
- },
- ),
- });
+ this.uploadError = MAXIMUM_FILE_UPLOAD_LIMIT_REACHED;
return false;
}
@@ -206,7 +196,7 @@ export default {
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
- createFlash({ message: skippedWarningMessage, types: FLASH_TYPES.WARNING });
+ this.uploadError = skippedWarningMessage;
}
// if this upload resulted in a new version being created, redirect user to the latest version
@@ -229,7 +219,7 @@ export default {
},
onUploadDesignError() {
this.resetFilesToBeSaved();
- createFlash({ message: UPLOAD_DESIGN_ERROR });
+ this.uploadError = UPLOAD_DESIGN_ERROR;
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
@@ -260,21 +250,21 @@ export default {
},
onDesignDeleteError() {
const errorMessage = designDeletionError(this.selectedDesigns.length);
- createFlash({ message: errorMessage });
+ this.uploadError = errorMessage;
},
onDesignDropzoneError() {
- createFlash({ message: UPLOAD_DESIGN_INVALID_FILETYPE_ERROR });
+ this.uploadError = UPLOAD_DESIGN_INVALID_FILETYPE_ERROR;
},
onExistingDesignDropzoneChange(files, existingDesignFilename) {
const filesArr = Array.from(files);
if (filesArr.length > 1) {
- createFlash({ message: EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE });
+ this.uploadError = EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE;
return;
}
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
- createFlash({ message: EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE });
+ this.uploadError = EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE;
return;
}
@@ -329,7 +319,7 @@ export default {
optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns),
})
.catch(() => {
- createFlash({ message: MOVE_DESIGN_ERROR });
+ this.uploadError = MOVE_DESIGN_ERROR;
})
.finally(() => {
this.isReorderingInProgress = false;
@@ -338,6 +328,9 @@ export default {
onDesignMove(designs) {
this.reorderedDesigns = designs;
},
+ unsetUpdateError() {
+ this.uploadError = null;
+ },
},
dragOptions: {
animation: 200,
@@ -356,6 +349,15 @@ export default {
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
+
+ {{ uploadError }}
+
{
return someDesignsSkippedMessage(skippedFiles);
};
+
+export const MAXIMUM_FILE_UPLOAD_LIMIT_REACHED = sprintf(
+ s__(
+ 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
+ ),
+ {
+ upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
+ },
+);
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index d597c7e53bb..b71cfbb6112 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -7,12 +7,13 @@ import {
GlSprintf,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString, uniqueId } from 'lodash';
+import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
+import { n__ } from '~/locale';
import {
CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
@@ -21,7 +22,8 @@ import {
LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
-import { responseMessageFromSuccess } from '../utils/response_message_parser';
+import { responseFromSuccess } from '../utils/response_message_parser';
+import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
@@ -101,6 +103,7 @@ export default {
isLoading: false,
modalId: uniqueId('invite-members-modal-'),
newUsersToInvite: [],
+ invalidMembers: {},
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
source: 'unknown',
@@ -125,6 +128,16 @@ export default {
inviteDisabled() {
return this.newUsersToInvite.length === 0;
},
+ hasInvalidMembers() {
+ return !isEmpty(this.invalidMembers);
+ },
+ memberErrorTitle() {
+ return n__(
+ "InviteMembersModal|The following member couldn't be invited",
+ "InviteMembersModal|The following %d members couldn't be invited",
+ Object.keys(this.invalidMembers).length,
+ );
+ },
tasksToBeDoneEnabled() {
return (
(getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
@@ -218,7 +231,7 @@ export default {
},
sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
- this.invalidFeedbackMessage = '';
+ this.clearValidation();
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
@@ -242,12 +255,10 @@ export default {
...userId,
})
.then((response) => {
- const message = responseMessageFromSuccess(response);
+ const { error, message } = responseFromSuccess(response);
- if (message) {
- this.showInvalidFeedbackMessage({
- response: { data: { message } },
- });
+ if (error) {
+ this.showMemberErrors(message);
} else {
this.showSuccessMessage();
}
@@ -257,6 +268,13 @@ export default {
this.isLoading = false;
});
},
+ showMemberErrors(message) {
+ this.invalidMembers = message;
+ },
+ tokenName(username) {
+ // initial token creation hits this and nothing is found... so safe navigation
+ return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
+ },
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
@@ -264,8 +282,8 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
+ this.clearValidation();
this.isLoading = false;
- this.invalidFeedbackMessage = '';
this.newUsersToInvite = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
@@ -287,6 +305,11 @@ export default {
},
clearValidation() {
this.invalidFeedbackMessage = '';
+ this.invalidMembers = {};
+ },
+ removeToken(token) {
+ delete this.invalidMembers[memberName(token)];
+ this.invalidMembers = { ...this.invalidMembers };
},
},
labels: MEMBER_MODAL_LABELS,
@@ -324,23 +347,40 @@ export default {
-
+
+
+ {{ $options.labels.memberErrorListText }}
+
+ -
+ {{ tokenName(member) }}: {{ error }}
+
+
+
-
+
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 90d266c3155..f917ebc35c2 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -159,7 +159,7 @@ export default {
introText() {
return sprintf(this.labelIntroText, { name: this.name });
},
- validationState() {
+ exceptionState() {
return this.invalidFeedbackMessage ? false : null;
},
selectLabelId() {
@@ -306,11 +306,11 @@ export default {
-
+
@@ -320,7 +320,7 @@ export default {
-
+
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index 30c9294344e..b2bcb9a5906 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -3,6 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
+import { memberName } from '../utils/member_utils';
import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants';
export default {
@@ -23,7 +24,7 @@ export default {
type: String,
required: true,
},
- validationState: {
+ exceptionState: {
type: Boolean,
required: false,
default: false,
@@ -38,6 +39,10 @@ export default {
required: false,
default: null,
},
+ invalidMembers: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -109,13 +114,18 @@ export default {
this.hasBeenFocused = true;
},
- handleTokenRemove() {
+ handleTokenRemove(value) {
if (this.selectedTokens.length) {
+ this.$emit('token-remove', value);
+
return;
}
this.$emit('clear');
},
+ hasError(token) {
+ return Object.keys(this.invalidMembers).includes(memberName(token));
+ },
},
defaultQueryOptions: { without_project_bots: true, active: true },
i18n: {
@@ -127,7 +137,7 @@ export default {
-
-
+
+
{{ token.name }}
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index beb8f5b5aab..6141e5e9e0b 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -74,6 +74,9 @@ export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage member
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
+export const MEMBER_ERROR_LIST_TEXT = s__(
+ 'InviteMembersModal|Review the invite errors and try again:',
+);
export const MEMBER_MODAL_LABELS = {
modal: {
@@ -109,6 +112,7 @@ export const MEMBER_MODAL_LABELS = {
title: MEMBERS_TASKS_PROJECTS_TITLE,
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
+ memberErrorListText: MEMBER_ERROR_LIST_TEXT,
};
export const GROUP_MODAL_LABELS = {
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
new file mode 100644
index 00000000000..d85162626f1
--- /dev/null
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -0,0 +1,4 @@
+export function memberName(member) {
+ // user defined tokens(invites by email) will have email in `name` and will not contain `username`
+ return member.username || member.name;
+}
diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js
index db8ac303dc4..6e6431b89d9 100644
--- a/app/assets/javascripts/invite_members/utils/response_message_parser.js
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -1,15 +1,4 @@
-import { isString } from 'lodash';
-
-function responseKeyedMessageParsed(keyedMessage) {
- try {
- const keys = Object.keys(keyedMessage);
- const msg = keyedMessage[keys[0]];
-
- return msg;
- } catch {
- return '';
- }
-}
+import { isString, isArray } from 'lodash';
export function responseMessageFromError(response) {
if (!response?.response?.data) {
@@ -23,9 +12,9 @@ export function responseMessageFromError(response) {
return data.error || data.message?.error || data.message || '';
}
-export function responseMessageFromSuccess(response) {
+export function responseFromSuccess(response) {
if (!response?.data) {
- return '';
+ return { error: false };
}
const { data } = response;
@@ -34,11 +23,19 @@ export function responseMessageFromSuccess(response) {
const { message } = data;
if (isString(message)) {
- return message;
+ return { message, error: true };
}
- return responseKeyedMessageParsed(message);
+ if (isArray(message)) {
+ return { message: message[0], error: true };
+ }
+ // we assume object now with our keyed format
+ return { message: { ...message }, error: true };
}
- return data.error || '';
+ if (data.error) {
+ return { message: data.error, error: true };
+ }
+
+ return { error: false };
}
diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 34f9fe778ea..3a13c123d77 100644
--- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
@@ -1,6 +1,6 @@