Hangar/frontend/components/projects/MemberList.vue
2021-04-06 21:33:03 -07:00

301 lines
10 KiB
Vue

<template>
<v-card>
<v-card-title>
{{ $t('project.members') }}
<template v-if="!alwaysEditing">
<v-tooltip v-if="!editing" bottom>
<template #activator="{ on }">
<v-btn v-if="$perms.canEditSubjectSettings" icon color="info" class="flex-right" v-on="on" @click="editing = true">
<v-icon>mdi-pencil</v-icon>
</v-btn>
</template>
<span>{{ $t('general.edit') }}</span>
</v-tooltip>
<v-btn v-else-if="!isEdited" icon color="error" class="flex-right" @click="editing = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
<v-btn
v-if="(alwaysEditing || isEdited) && !noSaveBtn"
color="success"
small
class="flex-right"
:loading="loading.save"
:disabled="!isEdited"
@click="save"
>
<v-icon left>mdi-check</v-icon>
{{ $t('general.save') }}
</v-btn>
</v-card-title>
<v-card-text>
<v-list>
<v-list-item
v-for="member in editingMembers"
:key="member.name"
:class="{ 'to-delete': member.toDelete, editing: member.editing, new: member.new }"
>
<UserAvatar :username="member.name" :avatar-url="$util.avatarUrl(member.name)" clazz="user-avatar-xs" />
<NuxtLink v-if="!isEditing" :to="'/' + member.name">{{ member.name }}</NuxtLink>
<span v-else>{{ member.name }}</span>
<template v-if="isEditing && (!member.editing || member.new) && member.roleAssignable">
<v-btn v-if="!member.toDelete" icon x-small color="error" class="ml-1" @click="removeMember(member)">
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn v-else icon x-small color="error" class="ml-1" @click="member.toDelete = false"><v-icon>mdi-undo</v-icon></v-btn>
</template>
<span v-if="!member.editing" class="flex-right">
<span v-if="member.roleAccepted">{{ member.roleTitle }}</span>
<span v-else>{{ $t('form.memberList.invitedAs', [member.roleTitle]) }}</span>
</span>
<v-select
v-else
v-model="member.roleId"
dense
hide-details
single-line
:items="roles"
item-text="title"
item-value="roleId"
class="flex-right"
style="max-width: 120px"
/>
<template v-if="isEditing && !member.toDelete && !member.new && member.roleAssignable">
<v-tooltip v-if="!member.editing" bottom>
<template #activator="{ on }">
<v-btn class="flex-right ml-1" icon x-small color="info" v-on="on" @click="member.editing = true">
<v-icon>mdi-pencil</v-icon>
</v-btn>
</template>
<span>{{ $t('form.memberList.editUser') }}</span>
</v-tooltip>
<v-btn v-else class="flex-right ml-1" icon x-small color="info" @click="stopEditing(member)">
<v-icon>mdi-undo</v-icon>
</v-btn>
</template>
</v-list-item>
<template v-if="isEditing">
<v-divider />
<v-list-item>
<v-autocomplete
v-model="selectedUser"
hide-details
hide-no-data
item-text="name"
auto-select-first
return-object
hide-selected
dense
filled
clearable
class="mt-2"
:search-input.sync="userSearch"
:items="users"
:loading="loading.search"
:label="$t('form.memberList.addUser')"
>
<template #append-outer>
<v-btn fab small class="input-append-btn" color="info" :disabled="!selectedUser" @click="addMember">
<v-icon>mdi-plus</v-icon>
</v-btn>
</template>
</v-autocomplete>
</v-list-item>
</template>
</v-list>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'nuxt-property-decorator';
import { PropType } from 'vue';
import { JoinableMember } from 'hangar-internal';
import { PaginatedResult, Role, User } from 'hangar-api';
import { UserAvatar } from '~/components/users';
import { HangarComponent } from '~/components/mixins';
interface EditableMember {
name: string;
roleTitle?: string;
roleId?: number;
roleAssignable: boolean;
roleAccepted?: boolean;
editing: boolean;
toDelete: boolean;
new: boolean;
}
@Component({
components: { UserAvatar },
})
export default class MemberList extends HangarComponent {
@Prop({ type: Array as PropType<JoinableMember[]>, default: () => [] })
members!: JoinableMember[];
@Prop({ type: Boolean, default: false })
alwaysEditing!: boolean;
@Prop({ type: Array as PropType<Role[]>, required: true })
roles!: Role[];
@Prop({ type: Function as PropType<(name: User) => boolean>, default: () => (_: User) => true })
searchFilter!: (user: User) => boolean;
@Prop({ type: Boolean, default: false })
noSaveBtn!: boolean;
@Prop({ type: Boolean, default: false })
org!: boolean;
editing: boolean = false;
editingMembers: EditableMember[] = [];
userSearch: string = '';
users: User[] = [];
selectedUser: User | null = null;
loading = {
save: false,
search: false,
};
get isEditing() {
return this.alwaysEditing || this.editing;
}
get isEdited() {
return this.editedMembers.length;
}
setupEditing() {
this.editingMembers = [];
this.editingMembers = this.convertMembers(this.members);
}
convertMembers(jms: JoinableMember[]): EditableMember[] {
return jms.map((jm) => ({
name: jm.user.name,
roleTitle: jm.role.role.title,
roleId: jm.role.role.roleId,
roleAccepted: jm.role.accepted,
roleAssignable: jm.role.role.assignable,
editing: false,
toDelete: false,
new: false,
}));
}
get editedMembers(): EditableMember[] {
return this.editingMembers.filter(
(em) =>
em.toDelete ||
(em.new && em.roleId) ||
(em.editing && !em.new && em.roleId !== this.members.find((jm) => jm.user.name === em.name)!.role.role.roleId)
);
}
stopEditing(member: EditableMember) {
const originalMember = this.members.find((jm) => jm.user.name === member.name)!;
member.roleTitle = originalMember.role.role.title;
member.roleId = originalMember.role.role.roleId;
member.editing = false;
}
removeMember(member: EditableMember) {
if (member.new) {
this.$delete(this.editingMembers, this.editingMembers.indexOf(member));
} else {
member.toDelete = true;
}
}
addMember() {
if (!this.selectedUser) return;
this.editingMembers.push({
name: this.selectedUser.name,
new: true,
editing: true,
roleAssignable: true,
toDelete: false,
});
this.selectedUser = null;
}
save() {
const editedMembers = this.editedMembers;
const deletedMembers = editedMembers.filter((em) => em.toDelete);
if (deletedMembers.length) {
// TODO should we confirm the deletion? You are already queuing up the deletion so maybe that's enough
}
this.loading.save = true;
const url = this.org
? `organizations/org/${this.$route.params.user}/members`
: `projects/project/${this.$route.params.author}/${this.$route.params.slug}/members`;
this.$api
.requestInternal(url, true, 'post', editedMembers)
.then(() => {
this.editing = false;
this.$nuxt.refresh().then(() => {
this.setupEditing();
});
})
.catch(this.$util.handleRequestError)
.finally(() => {
this.loading.save = false;
});
}
@Watch('userSearch')
onSearch(val: string) {
this.users = [];
if (this.loading.search) {
return;
}
this.loading.search = true;
this.$api
.request<PaginatedResult<User>>('users', false, 'get', {
query: val,
limit: 25,
offset: 0,
})
.then((users) => {
this.users = users.result.filter(this.searchFilter).filter((u) => this.editingMembers.findIndex((em) => em.name === u.name) === -1);
})
.catch(this.$util.handleRequestError)
.finally(() => {
this.loading.search = false;
});
}
@Watch('editing')
onEditToggle(val: boolean) {
if (val) {
this.setupEditing();
}
}
created() {
this.setupEditing();
}
}
</script>
<style lang="scss" scoped>
@import '~vuetify/src/styles/styles';
.to-delete {
background-color: rgba(map-get($red, 'lighten-2'), 0.3);
}
.editing {
background-color: rgba(map-get($blue, 'lighten-2'), 0.3);
}
.new {
background-color: rgba(map-get($light-green, 'accent-3'), 0.3);
}
</style>