summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/members/components/table
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-12-17 11:59:07 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-12-17 11:59:07 +0000
commit8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch)
tree544930fb309b30317ae9797a9683768705d664c4 /app/assets/javascripts/members/components/table
parent4b1de649d0168371549608993deac953eb692019 (diff)
downloadgitlab-ce-8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca.tar.gz
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/members/components/table')
-rw-r--r--app/assets/javascripts/members/components/table/created_at.vue40
-rw-r--r--app/assets/javascripts/members/components/table/expiration_datepicker.vue99
-rw-r--r--app/assets/javascripts/members/components/table/expires_at.vue66
-rw-r--r--app/assets/javascripts/members/components/table/member_action_buttons.vue57
-rw-r--r--app/assets/javascripts/members/components/table/member_avatar.vue35
-rw-r--r--app/assets/javascripts/members/components/table/member_source.vue27
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue151
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue72
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue94
9 files changed, 641 insertions, 0 deletions
diff --git a/app/assets/javascripts/members/components/table/created_at.vue b/app/assets/javascripts/members/components/table/created_at.vue
new file mode 100644
index 00000000000..0bad70894f9
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/created_at.vue
@@ -0,0 +1,40 @@
+<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>
diff --git a/app/assets/javascripts/members/components/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
new file mode 100644
index 00000000000..0a8af81c1d1
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
@@ -0,0 +1,99 @@
+<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);
+ },
+ disabled() {
+ return (
+ this.busy ||
+ !this.permissions.canUpdate ||
+ (this.permissions.canOverride && !this.member.isOverridden)
+ );
+ },
+ },
+ 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="disabled"
+ @input="handleInput"
+ @clear="handleClear"
+ />
+</template>
diff --git a/app/assets/javascripts/members/components/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue
new file mode 100644
index 00000000000..c91de061b50
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/expires_at.vue
@@ -0,0 +1,66 @@
+<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>
diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue
new file mode 100644
index 00000000000..c61ebec33bd
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue
@@ -0,0 +1,57 @@
+<script>
+import UserActionButtons from '../action_buttons/user_action_buttons.vue';
+import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
+import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
+import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
+import { MEMBER_TYPES } from '../../constants';
+
+export default {
+ name: 'MemberActionButtons',
+ components: {
+ UserActionButtons,
+ GroupActionButtons,
+ InviteActionButtons,
+ AccessRequestActionButtons,
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ memberType: {
+ type: String,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ actionButtonComponent() {
+ const dictionary = {
+ [MEMBER_TYPES.user]: 'user-action-buttons',
+ [MEMBER_TYPES.group]: 'group-action-buttons',
+ [MEMBER_TYPES.invite]: 'invite-action-buttons',
+ [MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',
+ };
+
+ return dictionary[this.memberType];
+ },
+ },
+};
+</script>
+
+<template>
+ <component
+ :is="actionButtonComponent"
+ v-if="actionButtonComponent"
+ :member="member"
+ :permissions="permissions"
+ :is-current-user="isCurrentUser"
+ />
+</template>
diff --git a/app/assets/javascripts/members/components/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue
new file mode 100644
index 00000000000..a1f98d4008a
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/member_avatar.vue
@@ -0,0 +1,35 @@
+<script>
+import { kebabCase } from 'lodash';
+import UserAvatar from '../avatars/user_avatar.vue';
+import InviteAvatar from '../avatars/invite_avatar.vue';
+import GroupAvatar from '../avatars/group_avatar.vue';
+
+export default {
+ name: 'MemberAvatar',
+ components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar },
+ props: {
+ memberType: {
+ type: String,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ avatarComponent() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${kebabCase(this.memberType)}-avatar`;
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="avatarComponent" :member="member" :is-current-user="isCurrentUser" />
+</template>
diff --git a/app/assets/javascripts/members/components/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue
new file mode 100644
index 00000000000..030d72c3420
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/member_source.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ name: 'MemberSource',
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ memberSource: {
+ type: Object,
+ required: true,
+ },
+ isDirectMember: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-if="isDirectMember">{{ __('Direct member') }}</span>
+ <a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{
+ memberSource.name
+ }}</a>
+</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
new file mode 100644
index 00000000000..da77e5caad2
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -0,0 +1,151 @@
+<script>
+import { mapState } from 'vuex';
+import { GlTable, GlBadge } from '@gitlab/ui';
+import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
+import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
+import { FIELDS } from '../../constants';
+import initUserPopovers from '~/user_popovers';
+import MemberAvatar from './member_avatar.vue';
+import MemberSource from './member_source.vue';
+import CreatedAt from './created_at.vue';
+import ExpiresAt from './expires_at.vue';
+import MemberActionButtons from './member_action_buttons.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',
+ components: {
+ GlTable,
+ GlBadge,
+ MemberAvatar,
+ CreatedAt,
+ ExpiresAt,
+ MembersTableCell,
+ MemberSource,
+ MemberActionButtons,
+ RoleDropdown,
+ RemoveGroupLinkModal,
+ ExpirationDatepicker,
+ LdapOverrideConfirmationModal: () =>
+ import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
+ },
+ computed: {
+ ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']),
+ filteredFields() {
+ return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field));
+ },
+ userIsLoggedIn() {
+ return this.currentUserId !== null;
+ },
+ },
+ mounted() {
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ },
+ methods: {
+ showField(field) {
+ if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) {
+ return true;
+ }
+
+ return this[field.showFunction]();
+ },
+ showActionsField() {
+ if (!this.userIsLoggedIn) {
+ return false;
+ }
+
+ return this.members.some(member => {
+ return (
+ canRemove(member, this.sourceId) ||
+ canResend(member) ||
+ canUpdate(member, this.currentUserId, this.sourceId) ||
+ canOverride(member)
+ );
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-table
+ v-bind="tableAttrs.table"
+ class="members-table"
+ data-testid="members-table"
+ head-variant="white"
+ stacked="lg"
+ :fields="filteredFields"
+ :items="members"
+ primary-key="id"
+ thead-class="border-bottom"
+ :empty-text="__('No members found')"
+ show-empty
+ :tbody-tr-attr="tableAttrs.tr"
+ >
+ <template #cell(account)="{ item: member }">
+ <members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
+ <member-avatar
+ :member-type="memberType"
+ :is-current-user="isCurrentUser"
+ :member="member"
+ />
+ </members-table-cell>
+ </template>
+
+ <template #cell(source)="{ item: member }">
+ <members-table-cell #default="{ isDirectMember }" :member="member">
+ <member-source :is-direct-member="isDirectMember" :member-source="member.source" />
+ </members-table-cell>
+ </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 #cell(maxRole)="{ item: member }">
+ <members-table-cell #default="{ permissions }" :member="member">
+ <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" />
+ <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
+ </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
+ :member-type="memberType"
+ :is-current-user="isCurrentUser"
+ :permissions="permissions"
+ :member="member"
+ />
+ </members-table-cell>
+ </template>
+
+ <template #head(actions)="{ label }">
+ <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
+ </template>
+ </gl-table>
+ <remove-group-link-modal />
+ <ldap-override-confirmation-modal />
+ </div>
+</template>
diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
new file mode 100644
index 00000000000..20aa01b96bc
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -0,0 +1,72 @@
+<script>
+import { mapState } from 'vuex';
+import { MEMBER_TYPES } from '../../constants';
+import {
+ isGroup,
+ isDirectMember,
+ isCurrentUser,
+ canRemove,
+ canResend,
+ canUpdate,
+} from '../../utils';
+
+export default {
+ name: 'MembersTableCell',
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['sourceId', 'currentUserId']),
+ isGroup() {
+ return isGroup(this.member);
+ },
+ isInvite() {
+ return Boolean(this.member.invite);
+ },
+ isAccessRequest() {
+ return Boolean(this.member.requestedAt);
+ },
+ memberType() {
+ if (this.isGroup) {
+ return MEMBER_TYPES.group;
+ } else if (this.isInvite) {
+ return MEMBER_TYPES.invite;
+ } else if (this.isAccessRequest) {
+ return MEMBER_TYPES.accessRequest;
+ }
+
+ return MEMBER_TYPES.user;
+ },
+ isDirectMember() {
+ return isDirectMember(this.member, this.sourceId);
+ },
+ isCurrentUser() {
+ return isCurrentUser(this.member, this.currentUserId);
+ },
+ canRemove() {
+ return canRemove(this.member, this.sourceId);
+ },
+ canResend() {
+ return canResend(this.member);
+ },
+ canUpdate() {
+ return canUpdate(this.member, this.currentUserId, this.sourceId);
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({
+ memberType: this.memberType,
+ isDirectMember: this.isDirectMember,
+ isCurrentUser: this.isCurrentUser,
+ permissions: {
+ canRemove: this.canRemove,
+ canResend: this.canResend,
+ canUpdate: this.canUpdate,
+ },
+ });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
new file mode 100644
index 00000000000..8ad45ab6920
--- /dev/null
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -0,0 +1,94 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import { mapActions } from 'vuex';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'RoleDropdown',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'),
+ },
+ props: {
+ member: {
+ type: Object,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDesktop: false,
+ busy: false,
+ };
+ },
+ computed: {
+ disabled() {
+ return this.busy || (this.permissions.canOverride && !this.member.isOverridden);
+ },
+ },
+ mounted() {
+ this.isDesktop = bp.isDesktop();
+
+ // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle
+ // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented
+ const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle');
+
+ if (dropdownToggle) {
+ dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown');
+ }
+ },
+ methods: {
+ ...mapActions(['updateMemberRole']),
+ handleSelect(value, name) {
+ if (value === this.member.accessLevel.integerValue) {
+ return;
+ }
+
+ this.busy = true;
+
+ this.updateMemberRole({
+ memberId: this.member.id,
+ accessLevel: { integerValue: value, stringValue: name },
+ })
+ .then(() => {
+ this.$toast.show(s__('Members|Role updated successfully.'));
+ this.busy = false;
+ })
+ .catch(() => {
+ this.busy = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="glDropdown"
+ :right="!isDesktop"
+ :text="member.accessLevel.stringValue"
+ :header-text="__('Change permissions')"
+ :disabled="disabled"
+ >
+ <gl-dropdown-item
+ v-for="(value, name) in member.validRoles"
+ :key="value"
+ is-check-item
+ :is-checked="value === member.accessLevel.integerValue"
+ data-qa-selector="access_level_link"
+ @click="handleSelect(value, name)"
+ >
+ {{ name }}
+ </gl-dropdown-item>
+ <ldap-dropdown-item
+ v-if="permissions.canOverride && member.isOverridden"
+ :member-id="member.id"
+ />
+ </gl-dropdown>
+</template>