Commit 22f986c4 authored by Peter Hegman's avatar Peter Hegman Committed by Martin Wortschack

Add date related fields to members table

Add `Access granted`, `Access expires`, `Invited`, and `Requested`
fields
parent 1f21a463
...@@ -62,3 +62,5 @@ export const MEMBER_TYPES = { ...@@ -62,3 +62,5 @@ export const MEMBER_TYPES = {
invite: 'invite', invite: 'invite',
accessRequest: 'accessRequest', accessRequest: 'accessRequest',
}; };
export const DAYS_TO_EXPIRE_SOON = 7;
<script>
import { GlSprintf } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'CreatedAt',
components: { GlSprintf, TimeAgoTooltip },
props: {
date: {
type: String,
required: false,
default: null,
},
createdBy: {
type: Object,
required: false,
default: null,
},
},
computed: {
showCreatedBy() {
return this.createdBy?.name && this.createdBy?.webUrl;
},
},
};
</script>
<template>
<span>
<gl-sprintf v-if="showCreatedBy" :message="s__('Members|%{time} by %{user}')">
<template #time>
<time-ago-tooltip :time="date" />
</template>
<template #user>
<a :href="createdBy.webUrl">{{ createdBy.name }}</a>
</template>
</gl-sprintf>
<time-ago-tooltip v-else :time="date" />
</span>
</template>
<script>
import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import {
approximateDuration,
differenceInSeconds,
formatDate,
getDayDifference,
} from '~/lib/utils/datetime_utility';
import { DAYS_TO_EXPIRE_SOON } from '../constants';
export default {
name: 'ExpiresAt',
components: { GlSprintf },
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
date: {
type: String,
required: false,
default: null,
},
},
computed: {
noExpirationSet() {
return this.date === null;
},
parsed() {
return new Date(this.date);
},
differenceInSeconds() {
return differenceInSeconds(new Date(), this.parsed);
},
isExpired() {
return this.differenceInSeconds <= 0;
},
inWords() {
return approximateDuration(this.differenceInSeconds);
},
formatted() {
return formatDate(this.parsed);
},
expiresSoon() {
return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON;
},
cssClass() {
return {
'gl-text-red-500': this.isExpired,
'gl-text-orange-500': this.expiresSoon,
};
},
},
};
</script>
<template>
<span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span>
<span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass">
<template v-if="isExpired">{{ s__('Members|Expired') }}</template>
<gl-sprintf v-else :message="s__('Members|in %{time}')">
<template #time>
{{ inWords }}
</template>
</gl-sprintf>
</span>
</template>
...@@ -5,6 +5,8 @@ import { FIELDS } from '../constants'; ...@@ -5,6 +5,8 @@ import { FIELDS } from '../constants';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue'; import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue'; import MemberSource from './member_source.vue';
import CreatedAt from './created_at.vue';
import ExpiresAt from './expires_at.vue';
import MembersTableCell from './members_table_cell.vue'; import MembersTableCell from './members_table_cell.vue';
export default { export default {
...@@ -12,6 +14,8 @@ export default { ...@@ -12,6 +14,8 @@ export default {
components: { components: {
GlTable, GlTable,
MemberAvatar, MemberAvatar,
CreatedAt,
ExpiresAt,
MembersTableCell, MembersTableCell,
MemberSource, MemberSource,
}, },
...@@ -51,6 +55,22 @@ export default { ...@@ -51,6 +55,22 @@ export default {
</members-table-cell> </members-table-cell>
</template> </template>
<template #cell(granted)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" />
</template>
<template #cell(invited)="{ item: { createdAt, createdBy } }">
<created-at :date="createdAt" :created-by="createdBy" />
</template>
<template #cell(requested)="{ item: { createdAt } }">
<created-at :date="createdAt" />
</template>
<template #cell(expires)="{ item: { expiresAt } }">
<expires-at :date="expiresAt" />
</template>
<template #head(actions)="{ label }"> <template #head(actions)="{ label }">
<span data-testid="col-actions" class="gl-sr-only">{{ label }}</span> <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
</template> </template>
......
...@@ -15681,6 +15681,18 @@ msgstr "" ...@@ -15681,6 +15681,18 @@ msgstr ""
msgid "Members with access to %{strong_start}%{group_name}%{strong_end}" msgid "Members with access to %{strong_start}%{group_name}%{strong_end}"
msgstr "" msgstr ""
msgid "Members|%{time} by %{user}"
msgstr ""
msgid "Members|Expired"
msgstr ""
msgid "Members|No expiration set"
msgstr ""
msgid "Members|in %{time}"
msgstr ""
msgid "Memory Usage" msgid "Memory Usage"
msgstr "" msgstr ""
......
import { mount, createWrapper } from '@vue/test-utils';
import { within } from '@testing-library/dom';
import { useFakeDate } from 'helpers/fake_date';
import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
describe('CreatedAt', () => {
// March 15th, 2020
useFakeDate(2020, 2, 15);
const date = '2020-03-01T00:00:00.000';
const dateTimeAgo = '2 weeks ago';
let wrapper;
const createComponent = propsData => {
wrapper = mount(CreatedAt, {
propsData: {
date,
...propsData,
},
});
};
const getByText = (text, options) =>
createWrapper(within(wrapper.element).getByText(text, options));
afterEach(() => {
wrapper.destroy();
});
describe('created at text', () => {
beforeEach(() => {
createComponent();
});
it('displays created at text', () => {
expect(getByText(dateTimeAgo).exists()).toBe(true);
});
it('uses `TimeAgoTooltip` component to display tooltip', () => {
expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
});
});
describe('when `createdBy` prop is provided', () => {
it('displays a link to the user that created the member', () => {
createComponent({
createdBy: {
name: 'Administrator',
webUrl: 'https://gitlab.com/root',
},
});
const link = getByText('Administrator');
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe('https://gitlab.com/root');
});
});
});
import { mount, createWrapper } from '@vue/test-utils';
import { within } from '@testing-library/dom';
import { useFakeDate } from 'helpers/fake_date';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
describe('ExpiresAt', () => {
// March 15th, 2020
useFakeDate(2020, 2, 15);
let wrapper;
const createComponent = propsData => {
wrapper = mount(ExpiresAt, {
propsData,
directives: {
GlTooltip: createMockDirective(),
},
});
};
const getByText = (text, options) =>
createWrapper(within(wrapper.element).getByText(text, options));
const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
afterEach(() => {
wrapper.destroy();
});
describe('when no expiration date is set', () => {
it('displays "No expiration set"', () => {
createComponent({ date: null });
expect(getByText('No expiration set').exists()).toBe(true);
});
});
describe('when expiration date is in the past', () => {
let expiredText;
beforeEach(() => {
createComponent({ date: '2019-03-15T00:00:00.000' });
expiredText = getByText('Expired');
});
it('displays "Expired"', () => {
expect(expiredText.exists()).toBe(true);
expect(expiredText.classes()).toContain('gl-text-red-500');
});
it('displays tooltip with formatted date', () => {
const tooltipDirective = getTooltipDirective(expiredText);
expect(tooltipDirective).not.toBeUndefined();
expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000');
});
});
describe('when expiration date is in the future', () => {
it.each`
date | expected | warningColor
${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false}
${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true}
${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true}
${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true}
${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true}
${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true}
${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true}
${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true}
`('displays "$expected"', ({ date, expected, warningColor }) => {
createComponent({ date });
const expiredText = getByText(expected);
expect(expiredText.exists()).toBe(true);
if (warningColor) {
expect(expiredText.classes()).toContain('gl-text-orange-500');
} else {
expect(expiredText.classes()).not.toContain('gl-text-orange-500');
}
});
});
});
...@@ -7,6 +7,8 @@ import { ...@@ -7,6 +7,8 @@ import {
import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
import * as initUserPopovers from '~/user_popovers'; import * as initUserPopovers from '~/user_popovers';
import { member as memberMock, invite, accessRequest } from '../mock_data'; import { member as memberMock, invite, accessRequest } from '../mock_data';
...@@ -30,7 +32,7 @@ describe('MemberList', () => { ...@@ -30,7 +32,7 @@ describe('MemberList', () => {
wrapper = mount(MembersTable, { wrapper = mount(MembersTable, {
localVue, localVue,
store: createStore(state), store: createStore(state),
stubs: ['member-avatar'], stubs: ['member-avatar', 'member-source', 'expires-at', 'created-at'],
}); });
}; };
...@@ -50,10 +52,10 @@ describe('MemberList', () => { ...@@ -50,10 +52,10 @@ describe('MemberList', () => {
field | label | member | expectedComponent field | label | member | expectedComponent
${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
${'granted'} | ${'Access granted'} | ${memberMock} | ${null} ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
${'invited'} | ${'Invited'} | ${invite} | ${null} ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
${'requested'} | ${'Requested'} | ${accessRequest} | ${null} ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
${'expires'} | ${'Access expires'} | ${memberMock} | ${null} ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt}
${'maxRole'} | ${'Max role'} | ${memberMock} | ${null} ${'maxRole'} | ${'Max role'} | ${memberMock} | ${null}
${'expiration'} | ${'Expiration'} | ${memberMock} | ${null} ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null}
`('renders the $label field', ({ field, label, member, expectedComponent }) => { `('renders the $label field', ({ field, label, member, expectedComponent }) => {
......
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