Commit 87fb4a5f authored by Peter Hegman's avatar Peter Hegman Committed by Kushal Pandya

Add expiration datepicker to members table

Part of a larger initiative to convert the group members view from
HAML to Vue
parent 8272c037
<script>
import { GlDatepicker } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { getDateInFuture } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
export default {
name: 'ExpirationDatepicker',
components: { GlDatepicker },
props: {
member: {
type: Object,
required: true,
},
permissions: {
type: Object,
required: true,
},
},
data() {
return {
selectedDate: null,
busy: false,
};
},
computed: {
minDate() {
// Members expire at the beginning of the day.
// The first selectable day should be tomorrow.
const today = new Date();
const beginningOfToday = new Date(today.setHours(0, 0, 0, 0));
return getDateInFuture(beginningOfToday, 1);
},
},
mounted() {
if (this.member.expiresAt) {
this.selectedDate = new Date(this.member.expiresAt);
}
},
methods: {
...mapActions(['updateMemberExpiration']),
handleInput(date) {
this.busy = true;
this.updateMemberExpiration({
memberId: this.member.id,
expiresAt: date,
})
.then(() => {
this.$toast.show(s__('Members|Expiration date updated successfully.'));
this.busy = false;
})
.catch(() => {
this.busy = false;
});
},
handleClear() {
this.busy = true;
this.updateMemberExpiration({
memberId: this.member.id,
expiresAt: null,
})
.then(() => {
this.$toast.show(s__('Members|Expiration date removed successfully.'));
this.selectedDate = null;
this.busy = false;
})
.catch(() => {
this.busy = false;
});
},
},
};
</script>
<template>
<!-- `:target="null"` allows the datepicker to be opened on focus -->
<!-- `:container="null"` renders the datepicker in the body to prevent conflicting CSS table styles -->
<gl-datepicker
v-model="selectedDate"
class="gl-max-w-full"
show-clear-button
:target="null"
:container="null"
:min-date="minDate"
:placeholder="__('Expiration date')"
:disabled="!permissions.canUpdate || busy"
@input="handleInput"
@clear="handleClear"
/>
</template>
......@@ -11,6 +11,7 @@ import MemberActionButtons from './member_action_buttons.vue';
import MembersTableCell from './members_table_cell.vue';
import RoleDropdown from './role_dropdown.vue';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import ExpirationDatepicker from './expiration_datepicker.vue';
export default {
name: 'MembersTable',
......@@ -25,6 +26,7 @@ export default {
MemberActionButtons,
RoleDropdown,
RemoveGroupLinkModal,
ExpirationDatepicker,
},
computed: {
...mapState(['members', 'tableFields']),
......@@ -90,6 +92,12 @@ export default {
</members-table-cell>
</template>
<template #cell(expiration)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
<expiration-datepicker :permissions="permissions" :member="member" />
</members-table-cell>
</template>
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
......
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => {
try {
......@@ -23,3 +24,21 @@ export const showRemoveGroupLinkModal = ({ commit }, groupLink) => {
export const hideRemoveGroupLinkModal = ({ commit }) => {
commit(types.HIDE_REMOVE_GROUP_LINK_MODAL);
};
export const updateMemberExpiration = async ({ state, commit }, { memberId, expiresAt }) => {
try {
await axios.put(
state.memberPath.replace(':id', memberId),
state.requestFormatter({ expires_at: expiresAt ? formatDate(expiresAt, 'isoDate') : '' }),
);
commit(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, {
memberId,
expiresAt: expiresAt ? formatDate(expiresAt, 'isoUtcDateTime') : null,
});
} catch (error) {
commit(types.RECEIVE_MEMBER_EXPIRATION_ERROR);
throw error;
}
};
export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS';
export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR';
export const RECEIVE_MEMBER_EXPIRATION_SUCCESS = 'RECEIVE_MEMBER_EXPIRATION_SUCCESS';
export const RECEIVE_MEMBER_EXPIRATION_ERROR = 'RECEIVE_MEMBER_EXPIRATION_ERROR';
export const HIDE_ERROR = 'HIDE_ERROR';
export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_REMOVE_GROUP_LINK_MODAL';
......
......@@ -19,6 +19,21 @@ export default {
);
state.showError = true;
},
[types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, { memberId, expiresAt }) {
const member = findMember(state, memberId);
if (!member) {
return;
}
Vue.set(member, 'expiresAt', expiresAt);
},
[types.RECEIVE_MEMBER_EXPIRATION_ERROR](state) {
state.errorMessage = s__(
"Members|An error occurred while updating the member's expiration date, please try again.",
);
state.showError = true;
},
[types.HIDE_ERROR](state) {
state.showError = false;
state.errorMessage = '';
......
......@@ -228,6 +228,11 @@
width: px-to-rem(50px);
}
}
.gl-datepicker-input {
width: px-to-rem(165px);
max-width: 100%;
}
}
.card-mobile {
......
......@@ -16244,6 +16244,9 @@ msgstr ""
msgid "Members|%{time} by %{user}"
msgstr ""
msgid "Members|An error occurred while updating the member's expiration date, please try again."
msgstr ""
msgid "Members|An error occurred while updating the member's role, please try again."
msgstr ""
......@@ -16268,6 +16271,12 @@ msgstr ""
msgid "Members|Are you sure you want to withdraw your access request for \"%{source}\""
msgstr ""
msgid "Members|Expiration date removed successfully."
msgstr ""
msgid "Members|Expiration date updated successfully."
msgstr ""
msgid "Members|Expired"
msgstr ""
......
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { nextTick } from 'vue';
import { GlDatepicker } from '@gitlab/ui';
import { useFakeDate } from 'helpers/fake_date';
import waitForPromises from 'helpers/wait_for_promises';
import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue';
import { member } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ExpirationDatepicker', () => {
// March 15th, 2020 3:00
useFakeDate(2020, 2, 15, 3);
let wrapper;
let actions;
let resolveUpdateMemberExpiration;
const $toast = {
show: jest.fn(),
};
const createStore = () => {
actions = {
updateMemberExpiration: jest.fn(
() =>
new Promise(resolve => {
resolveUpdateMemberExpiration = resolve;
}),
),
};
return new Vuex.Store({ actions });
};
const createComponent = (propsData = {}) => {
wrapper = mount(ExpirationDatepicker, {
propsData: {
member,
permissions: { canUpdate: true },
...propsData,
},
localVue,
store: createStore(),
mocks: {
$toast,
},
});
};
const findInput = () => wrapper.find('input');
const findDatepicker = () => wrapper.find(GlDatepicker);
afterEach(() => {
wrapper.destroy();
});
describe('datepicker input', () => {
it('sets `member.expiresAt` as initial date', async () => {
createComponent({ member: { ...member, expiresAt: '2020-03-17T00:00:00Z' } });
await nextTick();
expect(findInput().element.value).toBe('2020-03-17');
});
});
describe('props', () => {
beforeEach(() => {
createComponent();
});
it('sets `minDate` prop as tomorrow', () => {
expect(
findDatepicker()
.props('minDate')
.toISOString(),
).toBe(new Date('2020-3-16').toISOString());
});
it('sets `target` prop as `null` so datepicker opens on focus', () => {
expect(findDatepicker().props('target')).toBe(null);
});
it("sets `container` prop as `null` so table styles don't affect the datepicker styles", () => {
expect(findDatepicker().props('container')).toBe(null);
});
it('shows clear button', () => {
expect(findDatepicker().props('showClearButton')).toBe(true);
});
});
describe('when datepicker is changed', () => {
beforeEach(async () => {
createComponent();
findDatepicker().vm.$emit('input', new Date('2020-03-17'));
});
it('calls `updateMemberExpiration` Vuex action', () => {
expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), {
memberId: member.id,
expiresAt: new Date('2020-03-17'),
});
});
it('displays toast when successful', async () => {
resolveUpdateMemberExpiration();
await waitForPromises();
expect($toast.show).toHaveBeenCalledWith('Expiration date updated successfully.');
});
it('disables dropdown while waiting for `updateMemberExpiration` to resolve', async () => {
expect(findDatepicker().props('disabled')).toBe(true);
resolveUpdateMemberExpiration();
await waitForPromises();
expect(findDatepicker().props('disabled')).toBe(false);
});
});
describe('when datepicker is cleared', () => {
beforeEach(async () => {
createComponent();
findInput().setValue('2020-03-17');
await nextTick();
wrapper.find('[data-testid="clear-button"]').trigger('click');
});
it('calls `updateMemberExpiration` Vuex action', () => {
expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), {
memberId: member.id,
expiresAt: null,
});
});
it('displays toast when successful', async () => {
resolveUpdateMemberExpiration();
await waitForPromises();
expect($toast.show).toHaveBeenCalledWith('Expiration date removed successfully.');
});
it('disables datepicker while waiting for `updateMemberExpiration` to resolve', async () => {
expect(findDatepicker().props('disabled')).toBe(true);
resolveUpdateMemberExpiration();
await waitForPromises();
expect(findDatepicker().props('disabled')).toBe(false);
});
});
describe('when user does not have `canUpdate` permissions', () => {
it('disables datepicker', () => {
createComponent({ permissions: { canUpdate: false } });
expect(findDatepicker().props('disabled')).toBe(true);
});
});
});
......@@ -11,6 +11,7 @@ import MemberSource from '~/vue_shared/components/members/table/member_source.vu
import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue';
import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue';
import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue';
import * as initUserPopovers from '~/user_popovers';
import { member as memberMock, invite, accessRequest } from '../mock_data';
......@@ -44,6 +45,7 @@ describe('MemberList', () => {
'member-action-buttons',
'role-dropdown',
'remove-group-link-modal',
'expiration-datepicker',
],
});
};
......@@ -75,7 +77,7 @@ describe('MemberList', () => {
${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt}
${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
${'expiration'} | ${'Expiration'} | ${memberMock} | ${null}
${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
`('renders the $label field', ({ field, label, member, expectedComponent }) => {
createComponent({
members: [member],
......
......@@ -3,79 +3,121 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { members, group } from 'jest/vue_shared/components/members/mock_data';
import testAction from 'helpers/vuex_action_helper';
import { useFakeDate } from 'helpers/fake_date';
import httpStatusCodes from '~/lib/utils/http_status';
import * as types from '~/vuex_shared/modules/members/mutation_types';
import {
updateMemberRole,
showRemoveGroupLinkModal,
hideRemoveGroupLinkModal,
updateMemberExpiration,
} from '~/vuex_shared/modules/members/actions';
describe('Vuex members actions', () => {
let mock;
describe('update member actions', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('updateMemberRole', () => {
const memberId = members[0].id;
const accessLevel = { integerValue: 30, stringValue: 'Developer' };
const payload = {
memberId,
accessLevel,
};
const state = {
members,
memberPath: '/groups/foo-bar/-/group_members/:id',
requestFormatter: noop,
removeGroupLinkModalVisible: false,
groupLinkToRemove: null,
};
describe('successful request', () => {
it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => {
let requestPath;
mock.onPut().replyOnce(config => {
requestPath = config.url;
return [httpStatusCodes.OK, {}];
});
await testAction(updateMemberRole, payload, state, [
{
type: types.RECEIVE_MEMBER_ROLE_SUCCESS,
payload,
},
]);
beforeEach(() => {
mock = new MockAdapter(axios);
});
expect(requestPath).toBe('/groups/foo-bar/-/group_members/238');
});
afterEach(() => {
mock.restore();
});
describe('unsuccessful request', () => {
beforeEach(() => {
mock.onPut().replyOnce(httpStatusCodes.BAD_REQUEST, { message: 'Bad request' });
});
describe('updateMemberRole', () => {
const memberId = members[0].id;
const accessLevel = { integerValue: 30, stringValue: 'Developer' };
const payload = {
memberId,
accessLevel,
};
describe('successful request', () => {
it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => {
mock.onPut().replyOnce(httpStatusCodes.OK);
it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation`, async () => {
try {
await testAction(updateMemberRole, payload, state, [
{
type: types.RECEIVE_MEMBER_ROLE_SUCCESS,
payload,
},
]);
} catch {
// Do nothing
}
expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238');
});
});
describe('unsuccessful request', () => {
it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation and throws error`, async () => {
mock.onPut().networkError();
await expect(
testAction(updateMemberRole, payload, state, [
{
type: types.RECEIVE_MEMBER_ROLE_ERROR,
},
]),
).rejects.toThrowError(new Error('Network Error'));
});
});
});
describe('updateMemberExpiration', () => {
useFakeDate(2020, 2, 15, 3);
const memberId = members[0].id;
const expiresAt = '2020-3-17';
describe('successful request', () => {
describe('changing expiration date', () => {
it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => {
mock.onPut().replyOnce(httpStatusCodes.OK);
await testAction(updateMemberExpiration, { memberId, expiresAt }, state, [
{
type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS,
payload: { memberId, expiresAt: '2020-03-17T00:00:00Z' },
},
]);
expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238');
});
});
describe('removing the expiration date', () => {
it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => {
mock.onPut().replyOnce(httpStatusCodes.OK);
await testAction(updateMemberExpiration, { memberId, expiresAt: null }, state, [
{
type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS,
payload: { memberId, expiresAt: null },
},
]);
});
});
});
it('throws error', async () => {
await expect(testAction(updateMemberRole, payload, state)).rejects.toThrowError();
describe('unsuccessful request', () => {
it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_ERROR} mutation and throws error`, async () => {
mock.onPut().networkError();
await expect(
testAction(updateMemberExpiration, { memberId, expiresAt }, state, [
{
type: types.RECEIVE_MEMBER_EXPIRATION_ERROR,
},
]),
).rejects.toThrowError(new Error('Network Error'));
});
});
});
});
......
......@@ -3,36 +3,63 @@ import mutations from '~/vuex_shared/modules/members/mutations';
import * as types from '~/vuex_shared/modules/members/mutation_types';
describe('Vuex members mutations', () => {
describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => {
it('updates member', () => {
const state = {
describe('update member mutations', () => {
let state;
beforeEach(() => {
state = {
members,
showError: false,
errorMessage: '',
};
});
const accessLevel = { integerValue: 30, stringValue: 'Developer' };
describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => {
it('updates member', () => {
const accessLevel = { integerValue: 30, stringValue: 'Developer' };
mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, {
memberId: members[0].id,
accessLevel,
mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, {
memberId: members[0].id,
accessLevel,
});
expect(state.members[0].accessLevel).toEqual(accessLevel);
});
});
describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => {
it('shows error message', () => {
mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state);
expect(state.members[0].accessLevel).toEqual(accessLevel);
expect(state.showError).toBe(true);
expect(state.errorMessage).toBe(
"An error occurred while updating the member's role, please try again.",
);
});
});
});
describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => {
it('shows error message', () => {
const state = {
showError: false,
errorMessage: '',
};
describe(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, () => {
it('updates member', () => {
const expiresAt = '2020-03-17T00:00:00Z';
mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state);
mutations[types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, {
memberId: members[0].id,
expiresAt,
});
expect(state.showError).toBe(true);
expect(state.errorMessage).toBe(
"An error occurred while updating the member's role, please try again.",
);
expect(state.members[0].expiresAt).toEqual(expiresAt);
});
});
describe(types.RECEIVE_MEMBER_EXPIRATION_ERROR, () => {
it('shows error message', () => {
mutations[types.RECEIVE_MEMBER_EXPIRATION_ERROR](state);
expect(state.showError).toBe(true);
expect(state.errorMessage).toBe(
"An error occurred while updating the member's expiration date, please try again.",
);
});
});
});
......
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