diff options
Diffstat (limited to 'app/assets/javascripts/members/components/table')
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> |