mirror of
https://github.com/HangarMC/Hangar.git
synced 2024-12-27 07:03:26 +08:00
301 lines
10 KiB
Vue
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" 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>
|