Commit 594ff713 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '352970-move-isloading-management-to-parent' into 'master'

Part 1 - Move isLoading and invalid state to parents of invite_modal_base

See merge request gitlab-org/gitlab!82159
parents c6f16b9d 32ef9221
......@@ -4,6 +4,7 @@ import Api from '~/api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants';
import eventHub from '../event_hub';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import GroupSelect from './group_select.vue';
import InviteModalBase from './invite_modal_base.vue';
......@@ -55,6 +56,8 @@ export default {
},
data() {
return {
invalidFeedbackMessage: '',
isLoading: false,
modalId: uniqueId('invite-groups-modal-'),
groupToBeSharedWith: {},
};
......@@ -83,13 +86,19 @@ export default {
});
},
methods: {
showInvalidFeedbackMessage(response) {
this.invalidFeedbackMessage = getInvalidFeedbackMessage(response);
},
openModal() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
sendInvite({ accessLevel, expiresAt }) {
this.invalidFeedbackMessage = '';
this.isLoading = true;
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
: Api.groupShareWithGroup.bind(Api);
......@@ -101,18 +110,27 @@ export default {
expires_at: expiresAt,
})
.then(() => {
onSuccess();
this.showSuccessMessage();
})
.catch(onError);
.catch((e) => {
this.showInvalidFeedbackMessage(e);
})
.finally(() => {
this.isLoading = false;
});
},
resetFields() {
this.invalidFeedbackMessage = '';
this.isLoading = false;
this.groupToBeSharedWith = {};
},
showSuccessMessage() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.closeModal();
},
clearValidation() {
this.invalidFeedbackMessage = '';
},
},
labels: GROUP_MODAL_LABELS,
};
......@@ -129,10 +147,12 @@ export default {
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:submit-disabled="inviteDisabled"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
@reset="resetFields"
@submit="sendInvite"
>
<template #select="{ clearValidation }">
<template #select>
<group-select
v-model="groupToBeSharedWith"
:access-levels="accessLevels"
......
......@@ -21,6 +21,7 @@ import {
} from '../constants';
import eventHub from '../event_hub';
import { responseMessageFromSuccess } from '../utils/response_message_parser';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
......@@ -84,6 +85,8 @@ export default {
},
data() {
return {
invalidFeedbackMessage: '',
isLoading: false,
modalId: uniqueId('invite-members-modal-'),
newUsersToInvite: [],
selectedTasksToBeDone: [],
......@@ -152,6 +155,9 @@ export default {
}
},
methods: {
showInvalidFeedbackMessage(response) {
this.invalidFeedbackMessage = getInvalidFeedbackMessage(response);
},
partitionNewUsersToInvite() {
const [usersToInviteByEmail, usersToAddById] = partition(
this.newUsersToInvite,
......@@ -176,7 +182,10 @@ export default {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.invalidFeedbackMessage = '';
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const promises = [];
const baseData = {
......@@ -220,19 +229,17 @@ export default {
const message = responseMessageFromSuccess(responses);
if (message) {
onError({
response: {
data: {
message,
},
},
this.showInvalidFeedbackMessage({
response: { data: { message } },
});
} else {
onSuccess();
this.showSuccessMessage();
}
})
.catch(onError);
.catch((e) => this.showInvalidFeedbackMessage(e))
.finally(() => {
this.isLoading = false;
});
},
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
......@@ -241,6 +248,8 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
this.isLoading = false;
this.invalidFeedbackMessage = '';
this.newUsersToInvite = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
......@@ -260,6 +269,9 @@ export default {
onAccessLevelUpdate(val) {
this.selectedAccessLevel = val;
},
clearValidation() {
this.invalidFeedbackMessage = '';
},
},
labels: MEMBER_MODAL_LABELS,
};
......@@ -276,6 +288,8 @@ export default {
:label-search-field="$options.labels.searchField"
:form-group-description="$options.labels.placeHolder"
:submit-disabled="inviteDisabled"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
@reset="resetFields"
@submit="sendInvite"
@access-level="onAccessLevelUpdate"
......@@ -288,7 +302,7 @@ export default {
<span v-if="isCelebration">{{ $options.labels.modal.celebrate.intro }} </span>
<modal-confetti v-if="isCelebration" />
</template>
<template #select="{ clearValidation, validationState, labelId }">
<template #select="{ validationState, labelId }">
<members-token-select
v-model="newUsersToInvite"
class="gl-mb-2"
......
......@@ -10,19 +10,15 @@ import {
GlButton,
GlFormInput,
} from '@gitlab/ui';
import { unescape } from 'lodash';
import { sanitize } from '~/lib/dompurify';
import { sprintf } from '~/locale';
import {
ACCESS_LEVEL,
ACCESS_EXPIRE_DATE,
INVALID_FEEDBACK_MESSAGE_DEFAULT,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
HEADER_CLOSE_LABEL,
} from '../constants';
import { responseMessageFromError } from '../utils/response_message_parser';
export default {
components: {
......@@ -80,14 +76,22 @@ export default {
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
invalidFeedbackMessage: {
type: String,
required: false,
default: '',
},
},
data() {
// Be sure to check out reset!
return {
invalidFeedbackMessage: '',
selectedAccessLevel: this.defaultAccessLevel,
selectedDate: undefined,
isLoading: false,
minDate: new Date(),
};
},
......@@ -116,16 +120,9 @@ export default {
},
},
methods: {
showInvalidFeedbackMessage(response) {
const message = this.unescapeMsg(responseMessageFromError(response));
this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
},
reset() {
// This component isn't necessarily disposed,
// so we might need to reset it's state.
this.isLoading = false;
this.invalidFeedbackMessage = '';
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
......@@ -135,33 +132,15 @@ export default {
this.reset();
this.$refs.modal.hide();
},
clearValidation() {
this.invalidFeedbackMessage = '';
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
submit() {
this.isLoading = true;
this.invalidFeedbackMessage = '';
this.$emit('submit', {
onSuccess: () => {
this.isLoading = false;
},
onError: (...args) => {
this.isLoading = false;
this.showInvalidFeedbackMessage(...args);
},
data: {
accessLevel: this.selectedAccessLevel,
expiresAt: this.selectedDate,
},
});
},
unescapeMsg(message) {
return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
},
},
HEADER_CLOSE_LABEL,
ACCESS_EXPIRE_DATE,
......@@ -204,10 +183,7 @@ export default {
data-testid="members-form-group"
>
<label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
<slot
name="select"
v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
></slot>
<slot name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot>
</gl-form-group>
<label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label>
......
import { unescape } from 'lodash';
import { sanitize } from '~/lib/dompurify';
import { INVALID_FEEDBACK_MESSAGE_DEFAULT } from '../constants';
import { responseMessageFromError } from './response_message_parser';
const unescapeMsg = (message) => unescape(sanitize(message, { ALLOWED_TAGS: [] }));
export const getInvalidFeedbackMessage = (response) => {
const message = unescapeMsg(responseMessageFromError(response));
return message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
};
......@@ -10,20 +10,16 @@ import {
GlButton,
GlFormInput,
} from '@gitlab/ui';
import { unescape } from 'lodash';
import { sanitize } from '~/lib/dompurify';
import { sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
ACCESS_LEVEL,
ACCESS_EXPIRE_DATE,
INVALID_FEEDBACK_MESSAGE_DEFAULT,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
HEADER_CLOSE_LABEL,
} from '~/invite_members/constants';
import { responseMessageFromError } from '~/invite_members/utils/response_message_parser';
import {
OVERAGE_MODAL_LINK,
OVERAGE_MODAL_TITLE,
......@@ -91,6 +87,16 @@ export default {
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
invalidFeedbackMessage: {
type: String,
required: false,
default: '',
},
subscriptionSeats: {
type: Number,
required: false,
......@@ -100,10 +106,8 @@ export default {
data() {
// Be sure to check out reset!
return {
invalidFeedbackMessage: '',
selectedAccessLevel: this.defaultAccessLevel,
selectedDate: undefined,
isLoading: false,
minDate: new Date(),
hasOverage: false,
totalUserCount: null,
......@@ -152,16 +156,9 @@ export default {
},
},
methods: {
showInvalidFeedbackMessage(response) {
const message = this.unescapeMsg(responseMessageFromError(response));
this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
},
reset() {
// This component isn't necessarily disposed,
// so we might need to reset it's state.
this.isLoading = false;
this.invalidFeedbackMessage = '';
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
......@@ -174,33 +171,15 @@ export default {
this.reset();
this.$refs.modal.hide();
},
clearValidation() {
this.invalidFeedbackMessage = '';
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
submit() {
this.isLoading = true;
this.invalidFeedbackMessage = '';
this.$emit('submit', {
onSuccess: () => {
this.isLoading = false;
},
onError: (...args) => {
this.isLoading = false;
this.showInvalidFeedbackMessage(...args);
},
data: {
accessLevel: this.selectedAccessLevel,
expiresAt: this.selectedDate,
},
});
},
unescapeMsg(message) {
return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
},
checkOverage() {
// add a more complex check in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78287
// totalUserCount should be calculated there
......@@ -272,10 +251,7 @@ export default {
data-testid="members-form-group"
>
<label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
<slot
name="select"
v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
></slot>
<slot name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot>
</gl-form-group>
<label class="gl-font-weight-bold">{{ $options.i18n.ACCESS_LEVEL }}</label>
......
......@@ -21,7 +21,7 @@ import { propsData } from 'jest/invite_members/mock_data/modal_base';
describe('InviteModalBase', () => {
let wrapper;
const createComponent = (data = {}, props = {}, glFeatures = {}) => {
const createComponent = (props = {}, glFeatures = {}) => {
wrapper = shallowMountExtended(InviteModalBase, {
propsData: {
...propsData,
......@@ -30,9 +30,6 @@ describe('InviteModalBase', () => {
provide: {
...glFeatures,
},
data() {
return data;
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
......@@ -64,6 +61,7 @@ describe('InviteModalBase', () => {
const findOverageInviteButton = () => wrapper.findByTestId('invite-with-overage-button');
const findInitialModalContent = () => wrapper.findByTestId('invite-modal-initial-content');
const findOverageModalContent = () => wrapper.findByTestId('invite-modal-overage-content');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const clickBackButton = () => findBackButton().vm.$emit('click');
......@@ -114,11 +112,39 @@ describe('InviteModalBase', () => {
it("doesn't show the overage content", () => {
expect(findOverageModalContent().isVisible()).toBe(false);
});
it('renders the members form group', () => {
expect(findMembersFormGroup().props()).toEqual({
description: propsData.formGroupDescription,
invalidFeedback: '',
state: null,
});
});
});
it('with isLoading, shows loading for invite button', () => {
createComponent({
isLoading: true,
});
expect(findInviteButton().props('loading')).toBe(true);
});
it('with invalidFeedbackMessage, set members form group validation state', () => {
createComponent({
invalidFeedbackMessage: 'invalid message!',
});
expect(findMembersFormGroup().props()).toEqual({
description: propsData.formGroupDescription,
invalidFeedback: 'invalid message!',
state: false,
});
});
describe('displays overage modal', () => {
beforeEach(() => {
createComponent({}, {}, { glFeatures: { overageMembersModal: true } });
createComponent({}, { glFeatures: { overageMembersModal: true } });
clickInviteButton();
});
......
......@@ -50,6 +50,8 @@ describe('InviteGroupsModal', () => {
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const clickCancelButton = () => findCancelButton().vm.$emit('click');
const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val);
const findBase = () => wrapper.findComponent(InviteModalBase);
const hideModal = () => wrapper.findComponent(GlModal).vm.$emit('hide');
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
......@@ -70,7 +72,8 @@ describe('InviteGroupsModal', () => {
});
describe('submitting the invite form', () => {
describe('when sharing the group is successful', () => {
let apiResolve;
let apiReject;
const groupPostData = {
group_id: sharedGroup.id,
group_access: propsData.defaultAccessLevel,
......@@ -83,15 +86,38 @@ describe('InviteGroupsModal', () => {
triggerGroupSelect(sharedGroup);
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
jest.spyOn(Api, 'groupShareWithGroup').mockImplementation(
() =>
new Promise((resolve, reject) => {
apiResolve = resolve;
apiReject = reject;
}),
);
clickInviteButton();
});
it('shows loading', () => {
expect(findBase().props('isLoading')).toBe(true);
});
it('calls Api groupShareWithGroup with the correct params', () => {
expect(Api.groupShareWithGroup).toHaveBeenCalledWith(propsData.id, groupPostData);
});
describe('when succeeds', () => {
beforeEach(() => {
apiResolve({ data: groupPostData });
});
it('hides loading', () => {
expect(findBase().props('isLoading')).toBe(false);
});
it('has no error message', () => {
expect(findBase().props('invalidFeedbackMessage')).toBe('');
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
......@@ -99,18 +125,9 @@ describe('InviteGroupsModal', () => {
});
});
describe('when sharing the group fails', () => {
describe('when fails', () => {
beforeEach(() => {
createInviteGroupToGroupWrapper();
triggerGroupSelect(sharedGroup);
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'groupShareWithGroup')
.mockRejectedValue({ response: { data: { success: false } } });
clickInviteButton();
apiReject({ response: { data: { success: false } } });
});
it('does not show the toast message on failure', () => {
......@@ -121,17 +138,14 @@ describe('InviteGroupsModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
});
describe('clearing the invalid state and message', () => {
it('clears the error when the cancel button is clicked', async () => {
clickCancelButton();
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
});
it('clears the error when the modal is hidden', async () => {
wrapper.findComponent(GlModal).vm.$emit('hide');
it.each`
desc | act
${'when the cancel button is clicked'} | ${clickCancelButton}
${'when the modal is hidden'} | ${hideModal}
${'when invite button is clicked'} | ${clickInviteButton}
${'when group input changes'} | ${() => triggerGroupSelect(sharedGroup)}
`('clears the error, $desc', async ({ act }) => {
act();
await nextTick();
......@@ -139,5 +153,4 @@ describe('InviteGroupsModal', () => {
});
});
});
});
});
......@@ -16,15 +16,12 @@ import { propsData } from '../mock_data/modal_base';
describe('InviteModalBase', () => {
let wrapper;
const createComponent = (data = {}, props = {}) => {
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(InviteModalBase, {
propsData: {
...propsData,
...props,
},
data() {
return data;
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
......@@ -52,6 +49,7 @@ describe('InviteModalBase', () => {
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
describe('rendering the modal', () => {
beforeEach(() => {
......@@ -99,5 +97,33 @@ describe('InviteModalBase', () => {
expect(findDatepicker().exists()).toBe(true);
});
});
it('renders the members form group', () => {
expect(findMembersFormGroup().props()).toEqual({
description: propsData.formGroupDescription,
invalidFeedback: '',
state: null,
});
});
});
it('with isLoading, shows loading for invite button', () => {
createComponent({
isLoading: true,
});
expect(findInviteButton().props('loading')).toBe(true);
});
it('with invalidFeedbackMessage, set members form group validation state', () => {
createComponent({
invalidFeedbackMessage: 'invalid message!',
});
expect(findMembersFormGroup().props()).toEqual({
description: propsData.formGroupDescription,
invalidFeedback: 'invalid message!',
state: false,
});
});
});
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