This commit is contained in:
pa
2026-03-09 02:49:59 +09:00
parent 64b27ce7f1
commit 90a17bb0ba
39 changed files with 9487 additions and 4384 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,403 @@
<template>
<div>
<img
v-if="!groupDialog.loading"
:src="groupDialog.ref.bannerUrl"
class="cursor-pointer"
style="flex: none; width: 100%; aspect-ratio: 6/1; object-fit: cover; border-radius: var(--radius-md)"
@click="showFullscreenImageDialog(groupDialog.ref.bannerUrl)"
loading="lazy" />
</div>
<div class="flex flex-wrap items-start px-2.5" style="max-height: none">
<span v-if="groupDialog.instances.length" style="font-size: 12px; font-weight: bold; margin: 6px">
{{ t('dialog.group.info.instances') }}
</span>
<div v-for="room in groupDialog.instances" :key="room.tag" style="width: 100%">
<div style="margin: 6px 0" class="flex items-center">
<Location :location="room.tag" class="text-sm" />
<InstanceActionBar
class="ml-1"
:location="room.tag"
:currentlocation="lastLocation.location"
:instance="room.ref"
:friendcount="room.friendCount"
refresh-tooltip="Refresh player count"
:on-refresh="() => refreshInstancePlayerCount(room.tag)" />
</div>
<div
v-if="room.users.length"
class="flex flex-wrap items-start"
style="margin: 8px 0; padding: 0; max-height: unset">
<div
v-for="user in room.users"
:key="user.id"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
@click="showUserDialog(user.id)">
<div class="relative inline-block flex-none size-9 mr-2.5" :class="userStatusClass(user)">
<img class="size-full rounded-full object-cover" :src="userImage(user)" loading="lazy" />
</div>
<div class="flex-1 overflow-hidden">
<span
class="block truncate font-medium leading-[18px]"
:style="{ color: user.$userColour }"
v-text="user.displayName" />
<span v-if="user.location === 'traveling'" class="block truncate text-xs">
<Spinner class="inline-block mr-1" />
<Timer :epoch="user.$travelingToTime" />
</span>
<span v-else class="block truncate text-xs">
<Timer :epoch="user.$location_at" />
</span>
</div>
</div>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.announcement') }}</span>
<span style="display: block" v-text="groupDialog.announcement.title" />
<div v-if="groupDialog.announcement.imageUrl" style="display: inline-block; margin-right: 6px">
<img
:src="groupDialog.announcement.imageUrl"
class="cursor-pointer"
style="
flex: none;
width: 60px;
height: 60px;
border-radius: var(--radius-md);
object-fit: cover;
"
@click="showFullscreenImageDialog(groupDialog.announcement.imageUrl)"
loading="lazy" />
</div>
<pre
class="text-xs"
style="
display: inline-block;
vertical-align: top;
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0;
"
>{{ groupDialog.announcement.text || '-' }}</pre
>
<br />
<div v-if="groupDialog.announcement.id" class="text-xs" style="float: right; margin-left: 6px">
<TooltipWrapper v-if="groupDialog.announcement.roleIds.length" side="top">
<template #content>
<span>{{ t('dialog.group.posts.visibility') }}</span>
<br />
<template v-for="roleId in groupDialog.announcement.roleIds" :key="roleId">
<template v-for="role in groupDialog.ref.roles" :key="roleId + role.id"
><span v-if="role.id === roleId" v-text="role.name"
/></template>
<span
v-if="
groupDialog.announcement.roleIds.indexOf(roleId) <
groupDialog.announcement.roleIds.length - 1
">
,&nbsp;
</span>
</template>
</template>
<Eye style="margin-right: 6px" />
</TooltipWrapper>
<DisplayName :userid="groupDialog.announcement.authorId" style="margin-right: 6px" />
<span v-if="groupDialog.announcement.editorId" style="margin-right: 6px">
({{ t('dialog.group.posts.edited_by') }}
<DisplayName :userid="groupDialog.announcement.editorId" />)
</span>
<TooltipWrapper side="bottom">
<template #content>
<span
>{{ t('dialog.group.posts.created_at') }}
{{ formatDateFilter(groupDialog.announcement.createdAt, 'long') }}</span
>
<template v-if="groupDialog.announcement.updatedAt !== groupDialog.announcement.createdAt">
<br />
<span
>{{ t('dialog.group.posts.edited_at') }}
{{ formatDateFilter(groupDialog.announcement.updatedAt, 'long') }}</span
>
</template>
</template>
<Timer :epoch="Date.parse(groupDialog.announcement.updatedAt)" />
</TooltipWrapper>
<template v-if="hasGroupPermission(groupDialog.ref, 'group-announcement-manage')">
<TooltipWrapper side="top" :content="t('dialog.group.posts.edit_tooltip')">
<Button
size="sm"
variant="ghost"
style="margin-left: 6px; padding: 0"
@click="showGroupPostEditDialog(groupDialog.id, groupDialog.announcement)"></Button>
</TooltipWrapper>
<TooltipWrapper side="top" :content="t('dialog.group.posts.delete_tooltip')">
<Button
size="sm"
variant="ghost"
style="margin-left: 6px; padding: 0"
@click="confirmDeleteGroupPost(groupDialog.announcement)"></Button>
</TooltipWrapper>
</template>
</div>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.rules') }}</span>
<pre
class="text-xs"
style="font-family: inherit; font-size: 12px; white-space: pre-wrap; margin: 0 0.5em 0 0"
>{{ groupDialog.ref.rules || '-' }}</pre
>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1" style="overflow: visible">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.group.info.upcoming_events')
}}</span>
<template v-if="upcomingCalenderEvents.length > 0">
<br />
<div class="grid-view flex flex-wrap gap-4 overflow-y-auto max-h-[360px] py-2.5">
<GroupCalendarEventCard
v-for="value in upcomingCalenderEvents"
:key="value.id"
:event="value"
:is-following="value.userInterest?.isFollowing"
@update-following-calendar-data="updateFollowingCalendarData"
mode="grid"
card-class="group-dialog-grid-card" />
</div>
</template>
<span v-else class="block truncate text-xs">-</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1" style="overflow: visible">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.past_events') }}</span>
<template v-if="pastCalenderEvents.length > 0">
<br />
<div class="grid-view flex flex-wrap gap-4 overflow-y-auto max-h-[360px] py-2.5">
<GroupCalendarEventCard
v-for="value in pastCalenderEvents"
:key="value.id"
:event="value"
:is-following="value.userInterest?.isFollowing"
@update-following-calendar-data="updateFollowingCalendarData"
mode="grid"
card-class="group-dialog-grid-card" />
</div>
</template>
<span v-else class="block truncate text-xs">-</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.members') }}</span>
<div class="block truncate text-xs">
{{ groupDialog.ref.memberCount }} ({{ groupDialog.ref.onlineMemberCount }})
</div>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.created_at') }}</span>
<span class="block truncate text-xs">{{ formatDateFilter(groupDialog.ref.createdAt, 'long') }}</span>
</div>
</div>
<div
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px]"
@click="showPreviousInstancesListDialog(groupDialog.ref)">
<div class="flex-1 overflow-hidden">
<div
class="block truncate font-medium leading-[18px]"
style="display: flex; justify-content: space-between; align-items: center">
<span>
{{ t('dialog.group.info.last_visited') }}
</span>
<TooltipWrapper side="top" :content="t('dialog.user.info.open_previous_instance')">
<MoreHorizontal style="margin-right: 16px" />
</TooltipWrapper>
</div>
<span class="block truncate text-xs">{{ formatDateFilter(groupDialog.lastVisit, 'long') }}</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.links') }}</span>
<div
v-if="groupDialog.ref.links && groupDialog.ref.links.length > 0"
style="margin-top: 6px"
class="flex">
<template v-for="(link, index) in groupDialog.ref.links" :key="index">
<TooltipWrapper v-if="link">
<template #content>
<span v-text="link" />
</template>
<img
:src="getFaviconUrl(link)"
style="
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 6px;
cursor: pointer;
"
@click.stop="openExternalLink(link)"
loading="lazy" />
</TooltipWrapper>
</template>
</div>
<div v-else class="block truncate text-xs">-</div>
</div>
</div>
<div class="inline-flex justify-between w-full">
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-1/2">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.url') }}</span>
<span class="block truncate text-xs"
>{{ groupDialog.ref.$url }}
<TooltipWrapper side="top" :content="t('dialog.group.info.url_tooltip')">
<Button
class="rounded-full ml-1 text-xs"
size="icon-sm"
variant="ghost"
@click="copyToClipboard(groupDialog.ref.$url)"
><Copy class="h-4 w-4" />
</Button> </TooltipWrapper
></span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-1/2">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{ t('dialog.group.info.id') }}</span>
<span class="block truncate text-xs"
>{{ groupDialog.id }}
<TooltipWrapper side="top" :content="t('dialog.group.info.id_tooltip')">
<Button
class="rounded-full ml-1 text-xs"
size="icon-sm"
variant="ghost"
@click="copyToClipboard(groupDialog.id)"
><Copy class="h-4 w-4" />
</Button> </TooltipWrapper
></span>
</div>
</div>
</div>
<div
v-if="groupDialog.ref.membershipStatus === 'member'"
class="border-t border-border"
style="width: 100%; margin-top: 8px">
<div style="width: 100%; display: flex; margin-top: 8px">
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.group.info.joined_at')
}}</span>
<span class="block truncate text-xs">{{
formatDateFilter(groupDialog.ref.myMember.joinedAt, 'long')
}}</span>
</div>
</div>
<div class="box-border flex items-center p-1.5 text-[13px] cursor-default w-[167px]">
<div class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.group.info.roles')
}}</span>
<span v-if="groupDialog.memberRoles.length === 0" class="block truncate text-xs"> - </span>
<span v-else class="block truncate text-xs">
<template v-for="(role, rIndex) in groupDialog.memberRoles" :key="rIndex">
<TooltipWrapper side="top">
<template #content>
<span>{{ t('dialog.group.info.role') }} {{ role.name }}</span>
<br />
<span
>{{ t('dialog.group.info.role_description') }} {{ role.description }}</span
>
<br />
<span v-if="role.updatedAt"
>{{ t('dialog.group.info.role_updated_at') }}
{{ formatDateFilter(role.updatedAt, 'long') }}</span
>
<span v-else
>{{ t('dialog.group.info.role_created_at') }}
{{ formatDateFilter(role.createdAt, 'long') }}</span
>
<br />
<span>{{ t('dialog.group.info.role_permissions') }}</span>
<br />
<template v-for="(permission, pIndex) in role.permissions" :key="pIndex">
<span>{{ permission }}</span>
<br />
</template>
</template>
<span
>{{ role.name
}}{{ rIndex < groupDialog.memberRoles.length - 1 ? ', ' : '' }}</span
>
</TooltipWrapper>
</template>
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Copy, Eye, MoreHorizontal } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import {
copyToClipboard,
formatDateFilter,
getFaviconUrl,
hasGroupPermission,
openExternalLink,
refreshInstancePlayerCount,
userImage,
userStatusClass
} from '../../../shared/utils';
import { useGalleryStore, useGroupStore, useInstanceStore, useLocationStore, useUserStore } from '../../../stores';
import { useGroupCalendarEvents } from './useGroupCalendarEvents';
import GroupCalendarEventCard from '../../../views/Tools/components/GroupCalendarEventCard.vue';
import InstanceActionBar from '../../InstanceActionBar.vue';
const props = defineProps({
showGroupPostEditDialog: {
type: Function,
required: true
},
confirmDeleteGroupPost: {
type: Function,
required: true
}
});
const { t } = useI18n();
const { showUserDialog } = useUserStore();
const { groupDialog } = storeToRefs(useGroupStore());
const { lastLocation } = storeToRefs(useLocationStore());
const { showFullscreenImageDialog } = useGalleryStore();
const instanceStore = useInstanceStore();
const { pastCalenderEvents, upcomingCalenderEvents, updateFollowingCalendarData } =
useGroupCalendarEvents(groupDialog);
/**
*
* @param groupRef
*/
function showPreviousInstancesListDialog(groupRef) {
instanceStore.showPreviousInstancesListDialog('group', groupRef);
}
</script>

View File

@@ -0,0 +1,242 @@
<template>
<template v-if="groupDialog.visible">
<span
v-if="hasGroupPermission(groupDialog.ref, 'group-members-viewall')"
style="font-weight: bold; font-size: 16px"
>{{ t('dialog.group.members.all_members') }}</span
>
<span v-else style="font-weight: bold; font-size: 16px">{{ t('dialog.group.members.friends_only') }}</span>
<div style="margin-top: 8px">
<Button
class="rounded-full h-6 w-6"
variant="ghost"
size="icon-sm"
:loading="isGroupMembersLoading"
circle
@click="loadAllGroupMembers">
<Spinner v-if="isGroupMembersLoading" /><RefreshCcw v-else
/></Button>
<Button
class="rounded-full h-6 w-6 ml-2"
size="icon-sm"
variant="ghost"
style="margin-left: 6px"
@click="downloadAndSaveJson(`${groupDialog.id}_members`, groupDialog.members)">
<Download class="h-4 w-4" />
</Button>
<span v-if="groupDialog.memberSearch.length" style="font-size: 14px; margin-left: 6px; margin-right: 6px"
>{{ groupDialog.memberSearchResults.length }}/{{ groupDialog.ref.memberCount }}</span
>
<span v-else style="font-size: 14px; margin-left: 6px; margin-right: 6px"
>{{ groupDialog.members.length }}/{{ groupDialog.ref.memberCount }}</span
>
<div
v-if="hasGroupPermission(groupDialog.ref, 'group-members-manage')"
style="float: right"
class="flex items-center">
<span style="margin-right: 6px">{{ t('dialog.group.members.sort_by') }}</span>
<Select
v-model="groupDialogMemberSortValue"
:disabled="isGroupMembersLoading || groupDialog.memberSearch.length > 0">
<SelectTrigger class="h-8 w-45 mr-1">
<SelectValue :placeholder="t('dialog.group.members.sort_by')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="item in groupDialogSortingOptions" :key="item.value" :value="item.value">
{{ t(item.name) }}
</SelectItem>
</SelectContent>
</Select>
<span class="ml-2 mr-1">{{ t('dialog.group.members.filter') }}</span>
<div style="display: inline-block; width: 220px">
<VirtualCombobox
v-model="groupDialogMemberFilterKey"
:groups="groupDialogMemberFilterGroups"
:disabled="isGroupMembersLoading || groupDialog.memberSearch.length > 0"
:placeholder="t('dialog.group.members.filter')"
:search-placeholder="t('dialog.group.members.search')"
:clearable="false"
:close-on-select="true">
<template #trigger="{ text }">
<span class="truncate">
{{ text || t('dialog.group.members.filter') }}
</span>
</template>
</VirtualCombobox>
</div>
</div>
<InputGroupField
v-model="groupDialog.memberSearch"
:disabled="!hasGroupPermission(groupDialog.ref, 'group-members-manage')"
clearable
size="sm"
:placeholder="t('dialog.group.members.search')"
style="margin-top: 8px; margin-bottom: 8px"
@input="groupMembersSearch" />
</div>
<div
v-if="groupDialog.memberSearch.length"
class="flex flex-wrap items-start"
style="margin-top: 8px; overflow: auto; max-height: 250px; min-width: 130px">
<div
v-for="user in groupDialog.memberSearchResults"
:key="user.id"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
@click="showUserDialog(user.userId)">
<div class="relative inline-block flex-none size-9 mr-2.5">
<img class="size-full rounded-full object-cover" :src="userImage(user.user)" loading="lazy" />
</div>
<div class="flex-1 overflow-hidden">
<span
class="block truncate font-medium leading-[18px]"
:style="{ color: user.user?.$userColour }"
v-text="user.user?.displayName" />
<span class="block truncate text-xs">
<template v-if="hasGroupPermission(groupDialog.ref, 'group-members-manage')">
<TooltipWrapper
v-if="user.isRepresenting"
side="top"
:content="t('dialog.group.members.representing')">
<Tag style="margin-right: 6px" />
</TooltipWrapper>
<TooltipWrapper v-if="user.visibility !== 'visible'" side="top">
<template #content>
<span>{{ t('dialog.group.members.visibility') }} {{ user.visibility }}</span>
</template>
<Eye style="margin-right: 6px" />
</TooltipWrapper>
<TooltipWrapper
v-if="!user.isSubscribedToAnnouncements"
side="top"
:content="t('dialog.group.members.unsubscribed_announcements')">
<MessageSquare style="margin-right: 6px" />
</TooltipWrapper>
<TooltipWrapper v-if="user.managerNotes" side="top">
<template #content>
<span>{{ t('dialog.group.members.manager_notes') }}</span>
<br />
<span>{{ user.managerNotes }}</span>
</template>
<Pencil style="margin-right: 6px" />
</TooltipWrapper>
</template>
<template v-for="roleId in user.roleIds" :key="roleId">
<template v-for="role in groupDialog.ref.roles" :key="role.id + roleId"
><span v-if="role.id === roleId" v-text="role.name" /></template
><template v-if="user.roleIds.indexOf(roleId) < user.roleIds.length - 1"
><span>,&nbsp;</span></template
>
</template>
</span>
</div>
</div>
</div>
<ul
v-else-if="groupDialog.members.length > 0"
class="infinite-list flex flex-wrap items-start"
style="margin-top: 8px; overflow: auto; max-height: 250px; min-width: 130px">
<li
v-for="user in groupDialog.members"
:key="user.id"
class="infinite-list-item box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
@click="showUserDialog(user.userId)">
<div class="relative inline-block flex-none size-9 mr-2.5">
<img class="size-full rounded-full object-cover" :src="userImage(user.user)" loading="lazy" />
</div>
<div class="flex-1 overflow-hidden">
<span
class="block truncate font-medium leading-[18px]"
:style="{ color: user.user?.$userColour }"
v-text="user.user?.displayName" />
<span class="block truncate text-xs">
<template v-if="hasGroupPermission(groupDialog.ref, 'group-members-manage')">
<TooltipWrapper
v-if="user.isRepresenting"
side="top"
:content="t('dialog.group.members.representing')">
<Tag style="margin-right: 6px" />
</TooltipWrapper>
<TooltipWrapper v-if="user.visibility !== 'visible'" side="top">
<template #content>
<span>{{ t('dialog.group.members.visibility') }} {{ user.visibility }}</span>
</template>
<Eye style="margin-right: 6px" />
</TooltipWrapper>
<TooltipWrapper
v-if="!user.isSubscribedToAnnouncements"
side="top"
:content="t('dialog.group.members.unsubscribed_announcements')">
<MessageSquare style="margin-right: 6px" />
</TooltipWrapper>
<TooltipWrapper v-if="user.managerNotes" side="top">
<template #content>
<span>{{ t('dialog.group.members.manager_notes') }}</span>
<br />
<span>{{ user.managerNotes }}</span>
</template>
<Pencil style="margin-right: 6px" />
</TooltipWrapper>
</template>
<template v-for="roleId in user.roleIds" :key="roleId">
<template v-for="role in groupDialog.ref.roles" :key="roleId + role.id"
><span v-if="role.id === roleId" v-text="role.name" /></template
><template v-if="user.roleIds.indexOf(roleId) < user.roleIds.length - 1"
><span>&nbsp;</span></template
>
</template>
</span>
</div>
</li>
<div
v-if="!isGroupMembersDone"
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer"
style="width: 100%; height: 45px; text-align: center"
@click="loadMoreGroupMembers">
<div v-if="!isGroupMembersLoading" class="flex-1 overflow-hidden">
<span class="block truncate font-medium leading-[18px]">{{
t('dialog.group.members.load_more')
}}</span>
</div>
</div>
</ul>
</template>
</template>
<script setup>
import { Download, Eye, MessageSquare, Pencil, RefreshCcw, Tag } from 'lucide-vue-next';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { InputGroupField } from '@/components/ui/input-group';
import { Spinner } from '@/components/ui/spinner';
import { VirtualCombobox } from '@/components/ui/virtual-combobox';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { downloadAndSaveJson, hasGroupPermission, userImage } from '../../../shared/utils';
import { useGroupStore, useUserStore } from '../../../stores';
import { groupDialogSortingOptions } from '../../../shared/constants';
import { useGroupMembers } from './useGroupMembers';
const { t } = useI18n();
const { showUserDialog } = useUserStore();
const { currentUser } = storeToRefs(useUserStore());
const { groupDialog } = storeToRefs(useGroupStore());
const { applyGroupMember, handleGroupMember } = useGroupStore();
const {
isGroupMembersDone,
isGroupMembersLoading,
groupDialogMemberSortValue,
groupDialogMemberFilterKey,
groupDialogMemberFilterGroups,
groupMembersSearch,
getGroupDialogGroupMembers,
loadMoreGroupMembers,
loadAllGroupMembers
} = useGroupMembers(groupDialog, { currentUser, applyGroupMember, handleGroupMember, t });
defineExpose({
getGroupDialogGroupMembers
});
</script>

View File

@@ -0,0 +1,80 @@
<template>
<Button
class="rounded-full"
variant="ghost"
size="icon-sm"
:disabled="isGroupGalleryLoading"
@click="getGroupGalleries">
<Spinner v-if="isGroupGalleryLoading" />
<RefreshCw v-else />
</Button>
<TabsUnderline
v-model="groupDialogGalleryCurrentName"
:items="groupGalleryTabs"
:unmount-on-hide="false"
class="mt-2.5">
<template
v-for="(gallery, index) in groupDialog.ref.galleries"
:key="`label-${index}`"
v-slot:[`label-${index}`]>
<span style="font-weight: bold; font-size: 16px" v-text="gallery.name" />
<i class="x-status-icon" style="margin-left: 6px" :class="groupGalleryStatus(gallery)" />
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
groupDialog.galleries[gallery.id] ? groupDialog.galleries[gallery.id].length : 0
}}</span>
</template>
<template
v-for="(gallery, index) in groupDialog.ref.galleries"
:key="`content-${index}`"
v-slot:[String(index)]>
<span class="text-muted-foreground" style="padding: 8px" v-text="gallery.description" />
<div
style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-top: 8px;
max-height: 600px;
overflow-y: auto;
">
<Card
v-for="image in groupDialog.galleries[gallery.id]"
:key="image.id"
class="p-0 overflow-hidden transition-shadow hover:shadow-md">
<img
:src="image.imageUrl"
:class="[' cursor-pointer', 'max-w-full', 'max-h-full']"
@click="showFullscreenImageDialog(image.imageUrl)"
loading="lazy" />
</Card>
</div>
</template>
</TabsUnderline>
</template>
<script setup>
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { RefreshCw } from 'lucide-vue-next';
import { Spinner } from '@/components/ui/spinner';
import { TabsUnderline } from '@/components/ui/tabs';
import { storeToRefs } from 'pinia';
import { useGalleryStore, useGroupStore } from '../../../stores';
import { useGroupGalleries } from './useGroupGalleries';
const { groupDialog } = storeToRefs(useGroupStore());
const { showFullscreenImageDialog } = useGalleryStore();
const {
isGroupGalleryLoading,
groupDialogGalleryCurrentName,
groupGalleryTabs,
groupGalleryStatus,
getGroupGalleries
} = useGroupGalleries(groupDialog);
defineExpose({
getGroupGalleries
});
</script>

View File

@@ -0,0 +1,136 @@
<template>
<template v-if="groupDialog.visible">
<span style="margin-right: 8px; vertical-align: top"
>{{ t('dialog.group.posts.posts_count') }} {{ groupDialog.posts.length }}</span
>
<InputGroupField
v-model="groupDialog.postsSearch"
clearable
size="sm"
:placeholder="t('dialog.group.posts.search_placeholder')"
style="width: 89%; margin-bottom: 8px"
@input="updateGroupPostSearch" />
<div class="flex flex-wrap items-start">
<div
v-for="post in groupDialog.postsFiltered"
:key="post.id"
class="box-border flex items-center p-1.5 text-[13px] w-full cursor-default">
<div class="flex-1 overflow-hidden">
<span style="display: block" v-text="post.title" />
<div v-if="post.imageUrl" style="display: inline-block; margin-right: 6px">
<img
:src="post.imageUrl"
class="cursor-pointer"
style="
flex: none;
width: 60px;
height: 60px;
border-radius: var(--radius-md);
object-fit: cover;
"
@click="showFullscreenImageDialog(post.imageUrl)"
loading="lazy" />
</div>
<pre
class="text-xs"
style="
display: inline-block;
vertical-align: top;
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0;
"
>{{ post.text || '-' }}</pre
>
<br />
<div v-if="post.authorId" class="text-xs" style="float: right; margin-left: 6px">
<TooltipWrapper v-if="post.roleIds.length" side="top">
<template #content>
<span>{{ t('dialog.group.posts.visibility') }}</span>
<br />
<template v-for="roleId in post.roleIds" :key="roleId">
<template v-for="role in groupDialog.ref.roles" :key="role.id + roleId"
><span v-if="role.id === roleId" v-text="role.name" />
</template>
<template v-if="post.roleIds.indexOf(roleId) < post.roleIds.length - 1"
><span>,&nbsp;</span></template
>
</template>
</template>
<Eye style="margin-right: 6px" />
</TooltipWrapper>
<DisplayName :userid="post.authorId" style="margin-right: 6px" />
<span v-if="post.editorId" style="margin-right: 6px"
>({{ t('dialog.group.posts.edited_by') }} <DisplayName :userid="post.editorId" />)</span
>
<TooltipWrapper side="bottom">
<template #content>
<span
>{{ t('dialog.group.posts.created_at') }}
{{ formatDateFilter(post.createdAt, 'long') }}</span
>
<template v-if="post.updatedAt !== post.createdAt">
<br />
<span
>{{ t('dialog.group.posts.edited_at') }}
{{ formatDateFilter(post.updatedAt, 'long') }}</span
>
</template>
</template>
<Timer :epoch="Date.parse(post.updatedAt)" />
</TooltipWrapper>
<template v-if="hasGroupPermission(groupDialog.ref, 'group-announcement-manage')">
<TooltipWrapper side="top" :content="t('dialog.group.posts.edit_tooltip')">
<Button
size="icon-sm"
class="h-6 w-6 text-xs text-muted-foreground hover:text-foreground"
variant="ghost"
@click="showGroupPostEditDialog(groupDialog.id, post)"
><Pencil class="h-4 w-4" />
</Button>
</TooltipWrapper>
<TooltipWrapper side="top" :content="t('dialog.group.posts.delete_tooltip')">
<Button
size="icon-sm"
class="h-6 w-6 text-xs text-muted-foreground hover:text-foreground"
variant="ghost"
@click="confirmDeleteGroupPost(post)"
><Trash2 class="h-4 w-4" />
</Button>
</TooltipWrapper>
</template>
</div>
</div>
</div>
</div>
</template>
</template>
<script setup>
import { Eye, Pencil, Trash2 } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import { InputGroupField } from '@/components/ui/input-group';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { formatDateFilter, hasGroupPermission } from '../../../shared/utils';
import { useGalleryStore, useGroupStore } from '../../../stores';
const props = defineProps({
showGroupPostEditDialog: {
type: Function,
required: true
},
confirmDeleteGroupPost: {
type: Function,
required: true
}
});
const { t } = useI18n();
const { groupDialog } = storeToRefs(useGroupStore());
const { updateGroupPostSearch } = useGroupStore();
const { showFullscreenImageDialog } = useGalleryStore();
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div style="margin-top: 8px">
<div class="flex justify-between">
<div class="flex gap-2 items-center">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="loading"
@click="$emit('refresh')">
<Spinner v-if="loading" />
<RefreshCw v-else />
</Button>
<Button
size="sm"
variant="outline"
@click="$emit('select-all', tableData.data)"
>{{ t('dialog.group_member_moderation.select_all') }}</Button
>
<span style="font-size: 14px; margin-left: 6px; margin-right: 6px">{{
tableData.data.length
}}</span>
</div>
<div class="flex gap-2 items-center">
<Button
variant="outline"
size="sm"
:disabled="!tableData.data.length"
@click="$emit('export')"
>{{ t('dialog.group_member_moderation.export_bans') }}</Button
>
<Button
variant="outline"
size="sm"
:disabled="!hasGroupPermission(groupRef, 'group-bans-manage')"
@click="$emit('import')"
>{{ t('dialog.group_member_moderation.import_bans') }}</Button
>
<InputGroupField
v-model="tableData.filters[0].value"
clearable
size="sm"
class="w-80"
:placeholder="t('dialog.group.members.search')" />
</div>
</div>
<DataTableLayout
style="margin-top: 8px"
:table="tanstackTable"
:loading="loading"
:page-sizes="pageSizes"
:total-items="totalItems" />
</div>
</template>
<script setup>
import { RefreshCw } from 'lucide-vue-next';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { InputGroupField } from '@/components/ui/input-group';
import { DataTableLayout } from '@/components/ui/data-table';
import { hasGroupPermission } from '@/shared/utils';
import { createColumns } from './groupMemberModerationBansColumns.jsx';
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
const props = defineProps({
loading: { type: Boolean, default: false },
tableData: { type: Object, required: true },
groupRef: { type: Object, default: () => ({}) },
pageSizes: { type: Array, required: true },
columnContext: { type: Object, required: true }
});
defineEmits(['refresh', 'select-all', 'export', 'import']);
const { t } = useI18n();
const bansSearch = computed(() =>
String(props.tableData.filters?.[0]?.value ?? '')
.trim()
.toLowerCase()
);
const filteredRows = computed(() => {
const rows = Array.isArray(props.tableData.data) ? props.tableData.data : [];
const q = bansSearch.value;
if (!q) {
return rows;
}
return rows.filter((r) => {
const name = (r?.$displayName ?? r?.user?.displayName ?? '').toString().toLowerCase();
return name.includes(q);
});
});
const columns = computed(() => createColumns(props.columnContext));
const { table: tanstackTable } = useVrcxVueTable({
persistKey: 'group-moderation:bans',
get data() {
return filteredRows.value;
},
columns,
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
initialPagination: { pageIndex: 0, pageSize: props.tableData.pageSize ?? 15 }
});
const totalItems = computed(() => tanstackTable.getFilteredRowModel().rows.length);
</script>

View File

@@ -0,0 +1,215 @@
<template>
<div>
<span class="name">{{ t('dialog.group_member_moderation.user_id') }}</span>
<br />
<InputGroupField
:model-value="selectUserId"
size="sm"
style="margin-top: 6px"
:placeholder="t('dialog.group_member_moderation.user_id_placeholder')"
clearable
@update:model-value="$emit('update:selectUserId', $event)" />
<Button
size="sm"
variant="outline"
style="margin-top: 8px"
:disabled="!selectUserId"
@click="$emit('select-user')"
>{{ t('dialog.group_member_moderation.select_user') }}</Button
>
<br />
<br />
<span class="name">{{ t('dialog.group_member_moderation.selected_users') }}</span>
<Button
class="rounded-full"
size="icon-sm"
variant="outline"
style="margin-left: 6px"
@click="$emit('clear-all')">
<Trash2 />
</Button>
<br />
<Badge
v-for="user in selectedUsersArray"
:key="user.id"
variant="outline"
style="margin-right: 6px; margin-top: 6px">
<TooltipWrapper v-if="user.membershipStatus !== 'member'" side="top">
<template #content>
<span>{{ t('dialog.group_member_moderation.user_isnt_in_group') }}</span>
</template>
<AlertTriangle style="margin-left: 3px; display: inline-block" />
</TooltipWrapper>
<span
v-text="user.user?.displayName || user.userId"
style="font-weight: bold; margin-left: 6px"></span>
<button
type="button"
style="
margin-left: 8px;
border: none;
background: transparent;
padding: 0;
display: inline-flex;
align-items: center;
color: inherit;
cursor: pointer;
"
@click="$emit('delete-user', user)">
<X class="h-3 w-3" />
</button>
</Badge>
<br />
<br />
<span class="name">{{ t('dialog.group_member_moderation.notes') }}</span>
<InputGroupTextareaField
:model-value="note"
class="text-xs"
:rows="2"
:placeholder="t('dialog.group_member_moderation.note_placeholder')"
style="margin-top: 6px"
input-class="resize-none min-h-0"
@update:model-value="$emit('update:note', $event)" />
<br />
<br />
<span class="name">{{ t('dialog.group_member_moderation.selected_roles') }}</span>
<br />
<Select :model-value="selectedRoles" multiple @update:model-value="$emit('update:selectedRoles', $event)">
<SelectTrigger style="margin-top: 6px">
<SelectValue :placeholder="t('dialog.group_member_moderation.choose_roles_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="role in groupRef.roles"
:key="role.id"
:value="role.id">
{{ role.name }}
</SelectItem>
</SelectContent>
</Select>
<br />
<br />
<span class="name">{{ t('dialog.group_member_moderation.actions') }}</span>
<br />
<div class="flex gap-2">
<Button
variant="outline"
:disabled="
Boolean(
!selectedRoles.length ||
progressCurrent ||
!hasGroupPermission(groupRef, 'group-roles-assign')
)
"
@click="$emit('add-roles')"
>{{ t('dialog.group_member_moderation.add_roles') }}</Button
>
<Button
variant="secondary"
:disabled="
Boolean(
!selectedRoles.length ||
progressCurrent ||
!hasGroupPermission(groupRef, 'group-roles-assign')
)
"
@click="$emit('remove-roles')"
>{{ t('dialog.group_member_moderation.remove_roles') }}</Button
>
<Button
variant="outline"
:disabled="
Boolean(
progressCurrent ||
!hasGroupPermission(groupRef, 'group-members-manage')
)
"
@click="$emit('save-note')"
>{{ t('dialog.group_member_moderation.save_note') }}</Button
>
<Button
variant="outline"
:disabled="
Boolean(
progressCurrent ||
!hasGroupPermission(groupRef, 'group-members-remove')
)
"
@click="$emit('kick')"
>{{ t('dialog.group_member_moderation.kick') }}</Button
>
<Button
variant="outline"
:disabled="
Boolean(
progressCurrent ||
!hasGroupPermission(groupRef, 'group-bans-manage')
)
"
@click="$emit('ban')"
>{{ t('dialog.group_member_moderation.ban') }}</Button
>
<Button
variant="outline"
:disabled="
Boolean(
progressCurrent ||
!hasGroupPermission(groupRef, 'group-bans-manage')
)
"
@click="$emit('unban')"
>{{ t('dialog.group_member_moderation.unban') }}</Button
>
<span v-if="progressCurrent" style="margin-top: 8px">
<Spinner class="inline-block ml-2 mr-2" />
{{ t('dialog.group_member_moderation.progress') }} {{ progressCurrent }}/{{ progressTotal }}
</span>
<Button
v-if="progressCurrent"
variant="secondary"
style="margin-left: 6px"
@click="$emit('cancel-progress')"
>{{ t('dialog.group_member_moderation.cancel') }}</Button
>
</div>
</div>
</template>
<script setup>
import { AlertTriangle, Trash2, X } from 'lucide-vue-next';
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { InputGroupField, InputGroupTextareaField } from '@/components/ui/input-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { hasGroupPermission } from '@/shared/utils';
defineProps({
selectUserId: { type: String, default: '' },
selectedUsersArray: { type: Array, default: () => [] },
selectedRoles: { type: Array, default: () => [] },
note: { type: String, default: '' },
progressCurrent: { type: Number, default: 0 },
progressTotal: { type: Number, default: 0 },
groupRef: { type: Object, default: () => ({}) }
});
defineEmits([
'update:selectUserId',
'update:note',
'update:selectedRoles',
'select-user',
'clear-all',
'delete-user',
'add-roles',
'remove-roles',
'save-note',
'kick',
'ban',
'unban',
'cancel-progress'
]);
const { t } = useI18n();
</script>

View File

@@ -0,0 +1,206 @@
<template>
<div style="margin-top: 8px">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="loading"
@click="$emit('refresh')">
<Spinner v-if="loading" />
<RefreshCw v-else />
</Button>
<br />
<TabsUnderline default-value="sent" :items="invitesTabs" :unmount-on-hide="false">
<template #label-sent>
<span style="font-weight: bold; font-size: 16px">{{
t('dialog.group_member_moderation.sent_invites')
}}</span>
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
invitesTable.data.length
}}</span>
</template>
<template #label-join>
<span style="font-weight: bold; font-size: 16px">{{
t('dialog.group_member_moderation.join_requests')
}}</span>
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
joinRequestsTable.data.length
}}</span>
</template>
<template #label-blocked>
<span style="font-weight: bold; font-size: 16px">{{
t('dialog.group_member_moderation.blocked_requests')
}}</span>
<span class="text-muted-foreground" style="font-size: 12px; margin-left: 6px">{{
blockedTable.data.length
}}</span>
</template>
<template #sent>
<Button
size="sm"
variant="outline"
@click="$emit('select-all', invitesTable.data)"
>{{ t('dialog.group_member_moderation.select_all') }}</Button
>
<DataTableLayout
style="margin-top: 8px"
:table="invitesTanstackTable"
:loading="loading"
:page-sizes="pageSizes"
:total-items="invitesTotalItems" />
<br />
<Button
variant="outline"
:disabled="inviteActionDisabled"
@click="$emit('delete-sent-invite')"
>{{ t('dialog.group_member_moderation.delete_sent_invite') }}</Button
>
</template>
<template #join>
<Button
size="sm"
variant="outline"
@click="$emit('select-all', joinRequestsTable.data)"
>{{ t('dialog.group_member_moderation.select_all') }}</Button
>
<DataTableLayout
style="margin-top: 8px"
:table="joinRequestsTanstackTable"
:loading="loading"
:page-sizes="pageSizes"
:total-items="joinRequestsTotalItems" />
<br />
<Button
variant="outline"
:disabled="inviteActionDisabled"
class="mr-2"
@click="$emit('accept-invite-request')"
>{{ t('dialog.group_member_moderation.accept_join_requests') }}</Button
>
<Button
variant="outline"
:disabled="inviteActionDisabled"
class="mr-2"
@click="$emit('reject-invite-request')"
>{{ t('dialog.group_member_moderation.reject_join_requests') }}</Button
>
<Button
variant="outline"
:disabled="inviteActionDisabled"
@click="$emit('block-join-request')"
>{{ t('dialog.group_member_moderation.block_join_requests') }}</Button
>
</template>
<template #blocked>
<Button
size="sm"
variant="outline"
@click="$emit('select-all', blockedTable.data)"
>{{ t('dialog.group_member_moderation.select_all') }}</Button
>
<DataTableLayout
style="margin-top: 8px"
:table="blockedTanstackTable"
:loading="loading"
:page-sizes="pageSizes"
:total-items="blockedTotalItems" />
<br />
<Button
variant="outline"
:disabled="inviteActionDisabled"
@click="$emit('delete-blocked-request')"
>{{ t('dialog.group_member_moderation.delete_blocked_requests') }}</Button
>
</template>
</TabsUnderline>
</div>
</template>
<script setup>
import { RefreshCw } from 'lucide-vue-next';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { TabsUnderline } from '@/components/ui/tabs';
import { DataTableLayout } from '@/components/ui/data-table';
import { hasGroupPermission } from '@/shared/utils';
import { createColumns as createInvitesColumns } from './groupMemberModerationInvitesColumns.jsx';
import { createColumns as createJoinRequestsColumns } from './groupMemberModerationJoinRequestsColumns.jsx';
import { createColumns as createBlockedColumns } from './groupMemberModerationBlockedColumns.jsx';
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
const props = defineProps({
loading: { type: Boolean, default: false },
invitesTable: { type: Object, required: true },
joinRequestsTable: { type: Object, required: true },
blockedTable: { type: Object, required: true },
groupRef: { type: Object, default: () => ({}) },
progressCurrent: { type: Number, default: 0 },
pageSizes: { type: Array, required: true },
columnContext: { type: Object, required: true }
});
defineEmits([
'refresh',
'select-all',
'delete-sent-invite',
'accept-invite-request',
'reject-invite-request',
'block-join-request',
'delete-blocked-request'
]);
const { t } = useI18n();
const invitesTabs = computed(() => [
{ value: 'sent', label: t('dialog.group_member_moderation.sent_invites') },
{ value: 'join', label: t('dialog.group_member_moderation.join_requests') },
{ value: 'blocked', label: t('dialog.group_member_moderation.blocked_requests') }
]);
const inviteActionDisabled = computed(() =>
Boolean(props.progressCurrent || !hasGroupPermission(props.groupRef, 'group-invites-manage'))
);
// ── Invites TanStack table ───────────────────────────────────
const invitesColumns = computed(() => createInvitesColumns(props.columnContext));
const { table: invitesTanstackTable } = useVrcxVueTable({
persistKey: 'group-moderation:invites',
get data() {
return computed(() => props.invitesTable.data).value;
},
columns: invitesColumns,
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
initialPagination: { pageIndex: 0, pageSize: props.invitesTable.pageSize ?? 15 }
});
const invitesTotalItems = computed(() => invitesTanstackTable.getFilteredRowModel().rows.length);
// ── Join Requests TanStack table ─────────────────────────────
const joinRequestsColumns = computed(() => createJoinRequestsColumns(props.columnContext));
const { table: joinRequestsTanstackTable } = useVrcxVueTable({
persistKey: 'group-moderation:join-requests',
get data() {
return computed(() => props.joinRequestsTable.data).value;
},
columns: joinRequestsColumns,
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
initialPagination: { pageIndex: 0, pageSize: props.joinRequestsTable.pageSize ?? 15 }
});
const joinRequestsTotalItems = computed(() => joinRequestsTanstackTable.getFilteredRowModel().rows.length);
// ── Blocked TanStack table ───────────────────────────────────
const blockedColumns = computed(() => createBlockedColumns(props.columnContext));
const { table: blockedTanstackTable } = useVrcxVueTable({
persistKey: 'group-moderation:blocked',
get data() {
return computed(() => props.blockedTable.data).value;
},
columns: blockedColumns,
getRowId: (row) => String(row?.userId ?? row?.id ?? ''),
initialPagination: { pageIndex: 0, pageSize: props.blockedTable.pageSize ?? 15 }
});
const blockedTotalItems = computed(() => blockedTanstackTable.getFilteredRowModel().rows.length);
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div style="margin-top: 8px">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="loading"
@click="$emit('refresh')">
<Spinner v-if="loading" />
<RefreshCw v-else />
</Button>
<span style="font-size: 14px; margin-left: 6px; margin-right: 6px">{{
tableData.data.length
}}</span>
<br />
<div style="display: flex; justify-content: space-between; align-items: center">
<div>
<Select v-model="selectedAuditLogTypes" multiple>
<SelectTrigger style="margin: 8px 0; width: 250px">
<SelectValue
:placeholder="t('dialog.group_member_moderation.filter_type')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="type in auditLogTypes"
:key="type"
:value="type">
{{ getAuditLogTypeName(type) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Button variant="outline" @click="$emit('export')">{{
t('dialog.group_member_moderation.export_logs')
}}</Button>
</div>
</div>
<InputGroupField
v-model="tableData.filters[0].value"
clearable
size="sm"
:placeholder="t('dialog.group.members.search')"
style="margin-top: 8px; margin-bottom: 8px" />
<br />
<DataTableLayout
style="margin-top: 8px"
:table="tanstackTable"
:loading="loading"
:page-sizes="pageSizes"
:total-items="totalItems" />
</div>
</template>
<script setup>
import { RefreshCw } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { InputGroupField } from '@/components/ui/input-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DataTableLayout } from '@/components/ui/data-table';
import { getAuditLogTypeName } from './groupModerationUtils';
import { createColumns } from './groupMemberModerationLogsColumns.jsx';
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
const props = defineProps({
loading: { type: Boolean, default: false },
tableData: { type: Object, required: true },
auditLogTypes: { type: Array, default: () => [] },
pageSizes: { type: Array, required: true },
columnContext: { type: Object, required: true }
});
const emit = defineEmits(['refresh', 'export']);
const { t } = useI18n();
const selectedAuditLogTypes = ref([]);
defineExpose({ selectedAuditLogTypes });
const logsSearch = computed(() =>
String(props.tableData.filters?.[0]?.value ?? '')
.trim()
.toLowerCase()
);
const filteredRows = computed(() => {
const rows = Array.isArray(props.tableData.data) ? props.tableData.data : [];
const q = logsSearch.value;
if (!q) {
return rows;
}
return rows.filter((r) => {
const desc = (r?.description ?? '').toString().toLowerCase();
return desc.includes(q);
});
});
const columns = computed(() => createColumns(props.columnContext));
const { table: tanstackTable } = useVrcxVueTable({
persistKey: 'group-moderation:logs',
get data() {
return filteredRows.value;
},
columns,
getRowId: (row) => String(row?.id ?? `${row?.created_at ?? ''}:${row?.eventType ?? ''}`),
initialPagination: { pageIndex: 0, pageSize: props.tableData.pageSize ?? 15 }
});
const totalItems = computed(() => tanstackTable.getFilteredRowModel().rows.length);
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="mt-2">
<Button
class="rounded-full"
variant="outline"
size="icon-sm"
:disabled="loading"
@click="$emit('refresh')">
<Spinner v-if="loading" />
<RefreshCw v-else />
</Button>
<span class="ml-1.5 mr-1.5" style="font-size: 14px">
{{ tableData.data.length }}/{{ groupRef.memberCount }}
</span>
<div class="mt-1.5" style="float: right">
<span class="mr-1.5">{{ t('dialog.group.members.sort_by') }}</span>
<DropdownMenu>
<DropdownMenuTrigger
as-child
:disabled="sortFilterDisabled">
<Button
size="sm"
variant="outline"
:disabled="sortFilterDisabled"
@click.stop>
{{ t(memberSortOrder.name) }}
<ArrowDown class="ml-1.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="item in sortingOptions"
:key="item.name"
@click="$emit('sort-change', item)">
{{ t(item.name) }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span class="ml-2 mr-1.5">{{ t('dialog.group.members.filter') }}</span>
<DropdownMenu>
<DropdownMenuTrigger
as-child
:disabled="sortFilterDisabled">
<Button
size="sm"
variant="outline"
:disabled="sortFilterDisabled"
@click.stop>
{{ t(memberFilter.name) }}
<ArrowDown class="ml-1.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="item in filterOptions"
:key="item.name"
@click="$emit('filter-change', item)">
{{ t(item.name) }}
</DropdownMenuItem>
<template v-for="role in groupRef.roles" :key="role.name">
<DropdownMenuItem
v-if="!role.defaultRole"
@click="$emit('filter-change', role)">
{{ t(role.name) }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</div>
<InputGroupField
:model-value="memberSearch"
:disabled="!hasGroupPermission(groupRef, 'group-bans-manage')"
clearable
size="sm"
:placeholder="t('dialog.group.members.search')"
style="margin-top: 8px; margin-bottom: 8px"
@update:model-value="$emit('update:memberSearch', $event)"
@input="$emit('search')" />
<Button size="sm" variant="outline" @click="$emit('select-all', tableData.data)">{{
t('dialog.group_member_moderation.select_all')
}}</Button>
<DataTableLayout
v-if="tableData.data.length"
style="margin-top: 8px"
:table="tanstackTable"
:loading="loading"
:page-sizes="pageSizes"
:total-items="totalItems" />
</div>
</template>
<script setup>
import { ArrowDown, RefreshCw } from 'lucide-vue-next';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { InputGroupField } from '@/components/ui/input-group';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { DataTableLayout } from '@/components/ui/data-table';
import { hasGroupPermission } from '@/shared/utils';
import { createColumns } from './groupMemberModerationMembersColumns.jsx';
import { useVrcxVueTable } from '@/lib/table/useVrcxVueTable';
const props = defineProps({
loading: { type: Boolean, default: false },
tableData: { type: Object, required: true },
groupRef: { type: Object, default: () => ({}) },
memberSortOrder: { type: Object, required: true },
memberFilter: { type: Object, required: true },
memberSearch: { type: String, default: '' },
sortingOptions: { type: Array, required: true },
filterOptions: { type: Array, required: true },
pageSizes: { type: Array, required: true },
columnContext: { type: Object, required: true }
});
defineEmits(['refresh', 'update:memberSearch', 'search', 'sort-change', 'filter-change', 'select-all']);
const { t } = useI18n();
const sortFilterDisabled = computed(() =>
Boolean(
props.loading ||
props.memberSearch.length ||
!hasGroupPermission(props.groupRef, 'group-bans-manage')
)
);
const columns = computed(() => createColumns(props.columnContext));
const { table: tanstackTable } = useVrcxVueTable({
persistKey: 'group-moderation:members',
get data() {
return computed(() => props.tableData.data).value;
},
columns,
getRowId: (row) => String(row?.userId ?? ''),
initialPagination: { pageIndex: 0, pageSize: props.tableData.pageSize ?? 15 }
});
const totalItems = computed(() => tanstackTable.getFilteredRowModel().rows.length);
</script>

View File

@@ -0,0 +1,233 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
// ─── Mocks ───────────────────────────────────────────────────────────
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
}),
createI18n: () => ({
global: { t: (key) => key },
install: vi.fn()
})
}));
vi.mock('../../../../plugin/router', () => {
const { ref } = require('vue');
return {
router: {
beforeEach: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} }),
isReady: vi.fn().mockResolvedValue(true)
},
initRouter: vi.fn()
};
});
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
const { ref } = require('vue');
return {
...actual,
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} })
}))
};
});
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
vi.mock('../../../../service/database', () => ({
database: new Proxy(
{},
{
get: (_target, prop) => {
if (prop === '__esModule') return false;
return vi.fn().mockResolvedValue(null);
}
}
)
}));
vi.mock('../../../../service/config', () => ({
default: {
init: vi.fn(),
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
setString: vi.fn(),
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
setBool: vi.fn(),
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
setInt: vi.fn(),
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
setFloat: vi.fn(),
getObject: vi.fn().mockReturnValue(null),
setObject: vi.fn(),
getArray: vi.fn().mockReturnValue([]),
setArray: vi.fn(),
remove: vi.fn()
}
}));
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
vi.mock('../../../../service/watchState', () => ({
watchState: { isLoggedIn: false }
}));
vi.mock('../../../../service/request', () => ({
request: vi.fn().mockResolvedValue({ json: {} }),
processBulk: vi.fn(),
buildRequestInit: vi.fn(),
parseResponse: vi.fn(),
shouldIgnoreError: vi.fn(),
$throw: vi.fn(),
failedGetRequests: new Map()
}));
vi.mock('../../../../api', () => ({
groupRequest: {
getCachedGroupGallery: vi
.fn()
.mockResolvedValue({ json: [], params: {} })
},
userRequest: {}
}));
import GroupDialogPhotosTab from '../GroupDialogPhotosTab.vue';
import { useGroupStore } from '../../../../stores';
// ─── Helpers ─────────────────────────────────────────────────────────
const MOCK_GALLERIES = [
{
id: 'g1',
name: 'Photos',
description: 'General photos',
membersOnly: false
},
{
id: 'g2',
name: 'Screenshots',
description: 'Game screenshots',
membersOnly: true,
roleIdsToView: null
}
];
const MOCK_GALLERY_IMAGES = {
g1: [
{
id: 'img1',
imageUrl: 'https://img/photo1.png',
groupId: 'grp_1',
galleryId: 'g1'
},
{
id: 'img2',
imageUrl: 'https://img/photo2.png',
groupId: 'grp_1',
galleryId: 'g1'
}
],
g2: [
{
id: 'img3',
imageUrl: 'https://img/screen1.png',
groupId: 'grp_1',
galleryId: 'g2'
}
]
};
/**
* @param {object} overrides
*/
function mountComponent(overrides = {}) {
const pinia = createTestingPinia({
stubActions: false
});
const groupStore = useGroupStore(pinia);
groupStore.groupDialog = {
id: 'grp_1',
visible: true,
ref: {
galleries: [...MOCK_GALLERIES]
},
galleries: { ...MOCK_GALLERY_IMAGES },
...overrides
};
return mount(GroupDialogPhotosTab, {
global: {
plugins: [pinia],
stubs: {
RefreshCw: { template: '<svg class="refresh-icon" />' }
}
}
});
}
// ─── Tests ───────────────────────────────────────────────────────────
describe('GroupDialogPhotosTab.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
test('renders gallery names', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('Photos');
expect(wrapper.text()).toContain('Screenshots');
});
test('renders gallery image counts', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('2');
expect(wrapper.text()).toContain('1');
});
test('renders gallery descriptions', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('General photos');
});
test('renders gallery images', () => {
const wrapper = mountComponent();
const images = wrapper.findAll('img');
expect(images.length).toBeGreaterThan(0);
});
test('renders refresh button', () => {
const wrapper = mountComponent();
const button = wrapper.find('button');
expect(button.exists()).toBe(true);
});
test('renders zero count for empty gallery', () => {
const wrapper = mountComponent({
galleries: { g1: [], g2: [] }
});
expect(wrapper.text()).toContain('0');
});
});
describe('loading state', () => {
test('refresh button is enabled initially', () => {
const wrapper = mountComponent();
const button = wrapper.find('button');
expect(button.attributes('disabled')).toBeUndefined();
});
});
describe('with no galleries', () => {
test('renders without gallery tabs when ref.galleries is empty', () => {
const wrapper = mountComponent({
ref: { galleries: [] },
galleries: {}
});
// No gallery tabs should be rendered
expect(wrapper.text()).not.toContain('Photos');
expect(wrapper.text()).not.toContain('Screenshots');
});
});
});

View File

@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
// ─── Mocks ───────────────────────────────────────────────────────────
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key)
}),
createI18n: () => ({
global: { t: (key) => key },
install: vi.fn()
})
}));
vi.mock('../../../../plugin/router', () => {
const { ref } = require('vue');
return {
router: {
beforeEach: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} }),
isReady: vi.fn().mockResolvedValue(true)
},
initRouter: vi.fn()
};
});
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
const { ref } = require('vue');
return {
...actual,
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} })
}))
};
});
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
vi.mock('../../../../service/database', () => ({
database: new Proxy(
{},
{
get: (_target, prop) => {
if (prop === '__esModule') return false;
return vi.fn().mockResolvedValue(null);
}
}
)
}));
vi.mock('../../../../service/config', () => ({
default: {
init: vi.fn(),
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
setString: vi.fn(),
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
setBool: vi.fn(),
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
setInt: vi.fn(),
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
setFloat: vi.fn(),
getObject: vi.fn().mockReturnValue(null),
setObject: vi.fn(),
getArray: vi.fn().mockReturnValue([]),
setArray: vi.fn(),
remove: vi.fn()
}
}));
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
vi.mock('../../../../service/watchState', () => ({
watchState: { isLoggedIn: false }
}));
vi.mock('../../../../service/request', () => ({
request: vi.fn().mockResolvedValue({ json: {} }),
processBulk: vi.fn(),
buildRequestInit: vi.fn(),
parseResponse: vi.fn(),
shouldIgnoreError: vi.fn(),
$throw: vi.fn(),
failedGetRequests: new Map()
}));
import GroupDialogPostsTab from '../GroupDialogPostsTab.vue';
import { useGroupStore } from '../../../../stores';
// ─── Helpers ─────────────────────────────────────────────────────────
const MOCK_POSTS = [
{
id: 'post_1',
title: 'Welcome Post',
text: 'Hello everyone!',
imageUrl: 'https://img/post1.png',
authorId: 'usr_author1',
editorId: null,
roleIds: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
},
{
id: 'post_2',
title: 'Rules Update',
text: 'Updated rules here.',
imageUrl: null,
authorId: 'usr_author2',
editorId: 'usr_editor',
roleIds: ['role_1'],
createdAt: '2024-02-01T00:00:00Z',
updatedAt: '2024-02-15T00:00:00Z'
},
{
id: 'post_3',
title: 'Event Announcement',
text: '',
imageUrl: null,
authorId: 'usr_author1',
editorId: null,
roleIds: [],
createdAt: '2024-03-01T00:00:00Z',
updatedAt: '2024-03-01T00:00:00Z'
}
];
/**
* @param {Object} overrides
*/
function mountComponent(overrides = {}) {
const pinia = createTestingPinia({
stubActions: false
});
const groupStore = useGroupStore(pinia);
groupStore.groupDialog = {
id: 'grp_1',
visible: true,
posts: [...MOCK_POSTS],
postsFiltered: [...MOCK_POSTS],
postsSearch: '',
ref: {
roles: [
{ id: 'role_1', name: 'Admin' },
{ id: 'role_2', name: 'Member' }
],
permissions: []
},
...overrides
};
return mount(GroupDialogPostsTab, {
global: {
plugins: [pinia],
stubs: {
Eye: { template: '<svg class="eye-icon" />' },
Pencil: { template: '<svg class="pencil-icon" />' },
Trash2: { template: '<svg class="trash-icon" />' }
}
},
props: {
showGroupPostEditDialog: vi.fn(),
confirmDeleteGroupPost: vi.fn()
}
});
}
// ─── Tests ───────────────────────────────────────────────────────────
describe('GroupDialogPostsTab.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
test('renders post count', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('3');
});
test('renders all post titles', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('Welcome Post');
expect(wrapper.text()).toContain('Rules Update');
expect(wrapper.text()).toContain('Event Announcement');
});
test('renders post text', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('Hello everyone!');
expect(wrapper.text()).toContain('Updated rules here.');
});
test('renders dash for empty post text', () => {
const wrapper = mountComponent({
posts: [MOCK_POSTS[2]],
postsFiltered: [MOCK_POSTS[2]]
});
const preElements = wrapper.findAll('pre');
expect(preElements.some((pre) => pre.text() === '-')).toBe(true);
});
test('renders post image when imageUrl exists', () => {
const wrapper = mountComponent();
const images = wrapper.findAll('img');
expect(
images.some(
(img) => img.attributes('src') === 'https://img/post1.png'
)
).toBe(true);
});
test('does not render image for posts without imageUrl', () => {
const wrapper = mountComponent({
posts: [MOCK_POSTS[1]],
postsFiltered: [MOCK_POSTS[1]]
});
expect(wrapper.findAll('img')).toHaveLength(0);
});
test('renders search input', () => {
const wrapper = mountComponent();
expect(
wrapper.findComponent({ name: 'InputGroupField' }).exists()
).toBe(true);
});
test('renders empty state when no posts', () => {
const wrapper = mountComponent({ posts: [], postsFiltered: [] });
expect(wrapper.text()).toContain('0');
});
});
describe('filtered posts', () => {
test('renders only filtered posts', () => {
const wrapper = mountComponent({
postsFiltered: [MOCK_POSTS[0]]
});
const postItems = wrapper.findAll('.cursor-default');
// should only render 1 filtered post
expect(postItems).toHaveLength(1);
expect(wrapper.text()).toContain('Welcome Post');
});
});
});

View File

@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
}),
createI18n: () => ({
global: { t: (key) => key },
install: vi.fn()
})
}));
vi.mock('../../../../shared/utils', () => ({
hasGroupPermission: vi.fn((_group, permission) => {
if (_group?._mockPermissions) {
return _group._mockPermissions.includes(permission);
}
return true;
})
}));
import GroupModerationBulkActions from '../GroupModerationBulkActions.vue';
function mountComponent(props = {}) {
return mount(GroupModerationBulkActions, {
props: {
selectUserId: '',
selectedUsersArray: [],
selectedRoles: [],
note: '',
progressCurrent: 0,
progressTotal: 0,
groupRef: {
roles: [
{ id: 'role_1', name: 'Admin' },
{ id: 'role_2', name: 'Moderator' }
],
_mockPermissions: [
'group-roles-assign',
'group-members-manage',
'group-members-remove',
'group-bans-manage'
]
},
...props
},
global: {
stubs: {
AlertTriangle: { template: '<svg class="alert-icon" />' },
Trash2: { template: '<svg class="trash-icon" />' },
X: { template: '<svg class="x-icon" />' },
TooltipWrapper: {
template: '<div class="tooltip-stub"><slot /><slot name="content" /></div>'
}
}
}
});
}
describe('GroupModerationBulkActions.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
test('renders user ID input field', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('dialog.group_member_moderation.user_id');
});
test('renders selected users section', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('dialog.group_member_moderation.selected_users');
});
test('renders roles dropdown with available roles', () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain('dialog.group_member_moderation.selected_roles');
});
test('renders action buttons', () => {
const wrapper = mountComponent();
const text = wrapper.text();
expect(text).toContain('dialog.group_member_moderation.add_roles');
expect(text).toContain('dialog.group_member_moderation.remove_roles');
expect(text).toContain('dialog.group_member_moderation.save_note');
expect(text).toContain('dialog.group_member_moderation.kick');
expect(text).toContain('dialog.group_member_moderation.ban');
expect(text).toContain('dialog.group_member_moderation.unban');
});
test('renders selected user badges', () => {
const wrapper = mountComponent({
selectedUsersArray: [
{ id: 'usr_1', userId: 'usr_1', membershipStatus: 'member', user: { displayName: 'Alice' } },
{ id: 'usr_2', userId: 'usr_2', membershipStatus: 'member', user: { displayName: 'Bob' } }
]
});
expect(wrapper.text()).toContain('Alice');
expect(wrapper.text()).toContain('Bob');
});
test('shows warning tooltip for non-member users', () => {
const wrapper = mountComponent({
selectedUsersArray: [
{ id: 'usr_1', userId: 'usr_1', membershipStatus: 'banned', user: { displayName: 'Charlie' } }
]
});
expect(wrapper.text()).toContain('dialog.group_member_moderation.user_isnt_in_group');
});
test('does not show warning for member users', () => {
const wrapper = mountComponent({
selectedUsersArray: [
{ id: 'usr_1', userId: 'usr_1', membershipStatus: 'member', user: { displayName: 'Alice' } }
]
});
expect(wrapper.text()).not.toContain('dialog.group_member_moderation.user_isnt_in_group');
});
});
describe('progress indicator', () => {
test('shows progress when progressCurrent > 0', () => {
const wrapper = mountComponent({ progressCurrent: 3, progressTotal: 10 });
expect(wrapper.text()).toContain('dialog.group_member_moderation.progress');
expect(wrapper.text()).toContain('3/10');
});
test('shows cancel button during progress', () => {
const wrapper = mountComponent({ progressCurrent: 3, progressTotal: 10 });
expect(wrapper.text()).toContain('dialog.group_member_moderation.cancel');
});
test('hides progress when not in progress', () => {
const wrapper = mountComponent({ progressCurrent: 0 });
expect(wrapper.text()).not.toContain('dialog.group_member_moderation.progress');
});
});
describe('button disabled states', () => {
test('add/remove roles disabled when no roles selected', () => {
const wrapper = mountComponent({ selectedRoles: [] });
const addBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.add_roles')
);
expect(addBtn.attributes('disabled')).toBeDefined();
});
test('add/remove roles enabled when roles are selected', () => {
const wrapper = mountComponent({ selectedRoles: ['role_1'] });
const addBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.add_roles')
);
expect(addBtn.attributes('disabled')).toBeUndefined();
});
test('action buttons disabled during progress', () => {
const wrapper = mountComponent({
selectedRoles: ['role_1'],
progressCurrent: 5,
progressTotal: 10
});
const kickBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.kick')
);
expect(kickBtn.attributes('disabled')).toBeDefined();
});
test('select user button disabled when no user ID entered', () => {
const wrapper = mountComponent({ selectUserId: '' });
const selectBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.select_user')
);
expect(selectBtn.attributes('disabled')).toBeDefined();
});
test('select user button enabled when user ID is entered', () => {
const wrapper = mountComponent({ selectUserId: 'usr_test' });
const selectBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.select_user')
);
expect(selectBtn.attributes('disabled')).toBeUndefined();
});
});
describe('permissions', () => {
test('disables kick when missing group-members-remove permission', () => {
const wrapper = mountComponent({
groupRef: {
roles: [],
_mockPermissions: ['group-bans-manage']
}
});
const kickBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.kick')
);
expect(kickBtn.attributes('disabled')).toBeDefined();
});
test('disables ban/unban when missing group-bans-manage permission', () => {
const wrapper = mountComponent({
groupRef: {
roles: [],
_mockPermissions: ['group-members-remove']
}
});
const banBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.ban')
);
const unbanBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.unban')
);
expect(banBtn.attributes('disabled')).toBeDefined();
expect(unbanBtn.attributes('disabled')).toBeDefined();
});
});
describe('events', () => {
test('emits select-user on select button click', async () => {
const wrapper = mountComponent({ selectUserId: 'usr_test' });
const selectBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.select_user')
);
await selectBtn.trigger('click');
expect(wrapper.emitted('select-user')).toBeTruthy();
});
test('emits clear-all on trash button click', async () => {
const wrapper = mountComponent();
// The trash button is the rounded-full icon-sm button after "selected_users" label
const buttons = wrapper.findAll('button');
const trashBtn = buttons.find((b) => {
const classes = b.classes();
return classes.includes('rounded-full');
});
await trashBtn.trigger('click');
expect(wrapper.emitted('clear-all')).toBeTruthy();
});
test('emits delete-user when removing a selected user', async () => {
const user = { id: 'usr_1', userId: 'usr_1', membershipStatus: 'member', user: { displayName: 'Alice' } };
const wrapper = mountComponent({ selectedUsersArray: [user] });
// The X button is a native <button type="button"> inside each Badge
const deleteBtn = wrapper.find('button[type="button"]');
await deleteBtn.trigger('click');
expect(wrapper.emitted('delete-user')?.[0]?.[0]).toEqual(user);
});
test('emits ban on ban button click', async () => {
const wrapper = mountComponent();
const banBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.ban')
);
await banBtn.trigger('click');
expect(wrapper.emitted('ban')).toBeTruthy();
});
test('emits cancel-progress on cancel click', async () => {
const wrapper = mountComponent({ progressCurrent: 3, progressTotal: 10 });
const cancelBtn = wrapper.findAll('button').find((b) =>
b.text().includes('dialog.group_member_moderation.cancel')
);
await cancelBtn.trigger('click');
expect(wrapper.emitted('cancel-progress')).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,170 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
import { useGroupCalendarEvents } from '../useGroupCalendarEvents';
function createGroupDialog(calendar = []) {
return ref({
calendar
});
}
const PAST_DATE = '2020-01-01T00:00:00Z';
const FUTURE_DATE = '2099-12-31T23:59:59Z';
describe('useGroupCalendarEvents', () => {
describe('pastCalenderEvents', () => {
test('returns empty array when calendar is null', () => {
const groupDialog = ref({ calendar: null });
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
expect(pastCalenderEvents.value).toEqual([]);
});
test('returns empty array when calendar is undefined', () => {
const groupDialog = ref({});
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
expect(pastCalenderEvents.value).toEqual([]);
});
test('returns empty array when no past events exist', () => {
const groupDialog = createGroupDialog([
{ id: '1', endsAt: FUTURE_DATE }
]);
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
expect(pastCalenderEvents.value).toEqual([]);
});
test('returns only past events', () => {
const groupDialog = createGroupDialog([
{ id: '1', endsAt: PAST_DATE },
{ id: '2', endsAt: FUTURE_DATE },
{ id: '3', endsAt: PAST_DATE }
]);
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
expect(pastCalenderEvents.value).toHaveLength(2);
expect(pastCalenderEvents.value.map((e) => e.id)).toEqual([
'1',
'3'
]);
});
test('is reactive to calendar changes', () => {
const groupDialog = createGroupDialog([]);
const { pastCalenderEvents } = useGroupCalendarEvents(groupDialog);
expect(pastCalenderEvents.value).toHaveLength(0);
groupDialog.value.calendar = [{ id: '1', endsAt: PAST_DATE }];
expect(pastCalenderEvents.value).toHaveLength(1);
});
});
describe('upcomingCalenderEvents', () => {
test('returns empty array when calendar is null', () => {
const groupDialog = ref({ calendar: null });
const { upcomingCalenderEvents } =
useGroupCalendarEvents(groupDialog);
expect(upcomingCalenderEvents.value).toEqual([]);
});
test('returns empty array when no upcoming events exist', () => {
const groupDialog = createGroupDialog([
{ id: '1', endsAt: PAST_DATE }
]);
const { upcomingCalenderEvents } =
useGroupCalendarEvents(groupDialog);
expect(upcomingCalenderEvents.value).toEqual([]);
});
test('returns only upcoming events', () => {
const groupDialog = createGroupDialog([
{ id: '1', endsAt: PAST_DATE },
{ id: '2', endsAt: FUTURE_DATE },
{ id: '3', endsAt: FUTURE_DATE }
]);
const { upcomingCalenderEvents } =
useGroupCalendarEvents(groupDialog);
expect(upcomingCalenderEvents.value).toHaveLength(2);
expect(upcomingCalenderEvents.value.map((e) => e.id)).toEqual([
'2',
'3'
]);
});
test('past and upcoming are mutually exclusive', () => {
const events = [
{ id: '1', endsAt: PAST_DATE },
{ id: '2', endsAt: FUTURE_DATE }
];
const groupDialog = createGroupDialog(events);
const { pastCalenderEvents, upcomingCalenderEvents } =
useGroupCalendarEvents(groupDialog);
const allIds = [
...pastCalenderEvents.value.map((e) => e.id),
...upcomingCalenderEvents.value.map((e) => e.id)
];
expect(allIds).toHaveLength(2);
expect(new Set(allIds).size).toBe(2);
});
});
describe('updateFollowingCalendarData', () => {
test('updates an existing event by id', () => {
const groupDialog = createGroupDialog([
{ id: '1', title: 'Old Title', endsAt: FUTURE_DATE },
{ id: '2', title: 'Other', endsAt: FUTURE_DATE }
]);
const { updateFollowingCalendarData } =
useGroupCalendarEvents(groupDialog);
updateFollowingCalendarData({ id: '1', title: 'New Title' });
expect(groupDialog.value.calendar[0].title).toBe('New Title');
expect(groupDialog.value.calendar[0].endsAt).toBe(FUTURE_DATE);
});
test('does not modify other events', () => {
const groupDialog = createGroupDialog([
{ id: '1', title: 'Event 1', endsAt: FUTURE_DATE },
{ id: '2', title: 'Event 2', endsAt: FUTURE_DATE }
]);
const { updateFollowingCalendarData } =
useGroupCalendarEvents(groupDialog);
updateFollowingCalendarData({ id: '1', title: 'Updated' });
expect(groupDialog.value.calendar[1].title).toBe('Event 2');
});
test('does nothing when event id is not found', () => {
const events = [{ id: '1', title: 'Event 1', endsAt: FUTURE_DATE }];
const groupDialog = createGroupDialog([...events]);
const { updateFollowingCalendarData } =
useGroupCalendarEvents(groupDialog);
updateFollowingCalendarData({
id: 'nonexistent',
title: 'Updated'
});
expect(groupDialog.value.calendar[0].title).toBe('Event 1');
});
test('merges new properties into the event', () => {
const groupDialog = createGroupDialog([
{ id: '1', title: 'Event', endsAt: FUTURE_DATE }
]);
const { updateFollowingCalendarData } =
useGroupCalendarEvents(groupDialog);
updateFollowingCalendarData({
id: '1',
userInterest: { isFollowing: true }
});
expect(groupDialog.value.calendar[0].title).toBe('Event');
expect(groupDialog.value.calendar[0].userInterest.isFollowing).toBe(
true
);
});
});
});

View File

@@ -0,0 +1,248 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
vi.mock('../../../../api', () => ({
groupRequest: {
getCachedGroupGallery: vi.fn()
}
}));
import { useGroupGalleries } from '../useGroupGalleries';
import { groupRequest } from '../../../../api';
function createGroupDialog(overrides = {}) {
return ref({
id: 'grp_1',
ref: {
galleries: []
},
galleries: {},
...overrides
});
}
describe('useGroupGalleries', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('groupGalleryTabs', () => {
test('returns empty array when no galleries', () => {
const groupDialog = createGroupDialog();
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
expect(groupGalleryTabs.value).toEqual([]);
});
test('maps galleries to tabs with index values', () => {
const groupDialog = createGroupDialog({
ref: {
galleries: [
{ id: 'g1', name: 'Photos' },
{ id: 'g2', name: 'Screenshots' }
]
}
});
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
expect(groupGalleryTabs.value).toEqual([
{ value: '0', label: 'Photos' },
{ value: '1', label: 'Screenshots' }
]);
});
test('handles galleries with null name', () => {
const groupDialog = createGroupDialog({
ref: {
galleries: [{ id: 'g1', name: null }]
}
});
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
expect(groupGalleryTabs.value).toEqual([{ value: '0', label: '' }]);
});
test('is reactive to gallery changes', () => {
const groupDialog = createGroupDialog();
const { groupGalleryTabs } = useGroupGalleries(groupDialog);
expect(groupGalleryTabs.value).toHaveLength(0);
groupDialog.value.ref.galleries = [
{ id: 'g1', name: 'New Gallery' }
];
expect(groupGalleryTabs.value).toHaveLength(1);
});
});
describe('groupGalleryStatus', () => {
test('returns blue for non-members-only gallery', () => {
const groupDialog = createGroupDialog();
const { groupGalleryStatus } = useGroupGalleries(groupDialog);
expect(groupGalleryStatus({ membersOnly: false })).toEqual({
blue: true
});
});
test('returns green for members-only without role restriction', () => {
const groupDialog = createGroupDialog();
const { groupGalleryStatus } = useGroupGalleries(groupDialog);
expect(
groupGalleryStatus({ membersOnly: true, roleIdsToView: null })
).toEqual({ green: true });
});
test('returns red for role-restricted gallery', () => {
const groupDialog = createGroupDialog();
const { groupGalleryStatus } = useGroupGalleries(groupDialog);
expect(
groupGalleryStatus({
membersOnly: true,
roleIdsToView: ['role1']
})
).toEqual({ red: true });
});
});
describe('getGroupGalleries', () => {
test('resets galleries and tab before loading', async () => {
const groupDialog = createGroupDialog({
galleries: { old: [1, 2, 3] }
});
const { getGroupGalleries, groupDialogGalleryCurrentName } =
useGroupGalleries(groupDialog);
groupDialogGalleryCurrentName.value = '2';
await getGroupGalleries();
expect(groupDialogGalleryCurrentName.value).toBe('0');
});
test('sets loading state correctly during fetch', async () => {
const groupDialog = createGroupDialog({
ref: {
galleries: [{ id: 'g1', name: 'Gallery' }]
}
});
groupRequest.getCachedGroupGallery.mockResolvedValue({
json: [],
params: { groupId: 'grp_1' }
});
const { getGroupGalleries, isGroupGalleryLoading } =
useGroupGalleries(groupDialog);
expect(isGroupGalleryLoading.value).toBe(false);
const promise = getGroupGalleries();
expect(isGroupGalleryLoading.value).toBe(true);
await promise;
expect(isGroupGalleryLoading.value).toBe(false);
});
test('calls getCachedGroupGallery for each gallery', async () => {
const groupDialog = createGroupDialog({
ref: {
galleries: [
{ id: 'g1', name: 'A' },
{ id: 'g2', name: 'B' }
]
}
});
groupRequest.getCachedGroupGallery.mockResolvedValue({
json: [],
params: { groupId: 'grp_1' }
});
const { getGroupGalleries } = useGroupGalleries(groupDialog);
await getGroupGalleries();
expect(groupRequest.getCachedGroupGallery).toHaveBeenCalledTimes(2);
});
});
describe('getGroupGallery', () => {
test('populates gallery images from API response', async () => {
const groupDialog = createGroupDialog();
const { getGroupGallery } = useGroupGalleries(groupDialog);
groupRequest.getCachedGroupGallery.mockResolvedValueOnce({
json: [
{
groupId: 'grp_1',
galleryId: 'g1',
id: 'img1',
imageUrl: 'url1'
},
{
groupId: 'grp_1',
galleryId: 'g1',
id: 'img2',
imageUrl: 'url2'
}
],
params: { groupId: 'grp_1' }
});
await getGroupGallery('grp_1', 'g1');
expect(groupDialog.value.galleries['g1']).toHaveLength(2);
expect(groupDialog.value.galleries['g1'][0].id).toBe('img1');
});
test('ignores images from different groups', async () => {
const groupDialog = createGroupDialog();
const { getGroupGallery } = useGroupGalleries(groupDialog);
groupRequest.getCachedGroupGallery.mockResolvedValueOnce({
json: [
{
groupId: 'grp_other',
galleryId: 'g1',
id: 'img1',
imageUrl: 'url1'
}
],
params: { groupId: 'grp_other' }
});
await getGroupGallery('grp_1', 'g1');
expect(groupDialog.value.galleries['g1']).toBeUndefined();
});
test('stops pagination when fewer than 100 results returned', async () => {
const groupDialog = createGroupDialog();
const { getGroupGallery } = useGroupGalleries(groupDialog);
groupRequest.getCachedGroupGallery.mockResolvedValueOnce({
json: Array.from({ length: 50 }, (_, i) => ({
groupId: 'grp_1',
galleryId: 'g1',
id: `img${i}`,
imageUrl: `url${i}`
})),
params: { groupId: 'grp_1' }
});
await getGroupGallery('grp_1', 'g1');
expect(groupRequest.getCachedGroupGallery).toHaveBeenCalledTimes(1);
});
test('handles API errors gracefully', async () => {
const groupDialog = createGroupDialog();
const { getGroupGallery } = useGroupGalleries(groupDialog);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
groupRequest.getCachedGroupGallery.mockRejectedValueOnce(
new Error('API Error')
);
await expect(
getGroupGallery('grp_1', 'g1')
).resolves.toBeUndefined();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,464 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
vi.mock('../../../../api', () => ({
groupRequest: {
getGroupMembersSearch: vi.fn(),
getCachedGroupMember: vi.fn(),
getCachedGroupMembers: vi.fn()
},
userRequest: {}
}));
vi.mock('../../../../plugin/router', () => {
const { ref } = require('vue');
return {
router: {
beforeEach: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} }),
isReady: vi.fn().mockResolvedValue(true)
},
initRouter: vi.fn()
};
});
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
const { ref } = require('vue');
return {
...actual,
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} })
}))
};
});
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
vi.mock('../../../../service/database', () => ({
database: new Proxy(
{},
{
get: (_target, prop) => {
if (prop === '__esModule') return false;
return vi.fn().mockResolvedValue(null);
}
}
)
}));
vi.mock('../../../../service/config', () => ({
default: {
init: vi.fn(),
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
setString: vi.fn(),
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
setBool: vi.fn(),
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
setInt: vi.fn(),
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
setFloat: vi.fn(),
getObject: vi.fn().mockReturnValue(null),
setObject: vi.fn(),
getArray: vi.fn().mockReturnValue([]),
setArray: vi.fn(),
remove: vi.fn()
}
}));
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
vi.mock('../../../../service/watchState', () => ({
watchState: { isLoggedIn: false }
}));
vi.mock('../../../../service/request', () => ({
request: vi.fn().mockResolvedValue({ json: {} }),
processBulk: vi.fn(),
buildRequestInit: vi.fn(),
parseResponse: vi.fn(),
shouldIgnoreError: vi.fn(),
$throw: vi.fn(),
failedGetRequests: new Map()
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
}),
createI18n: () => ({
global: { t: (key) => key },
install: vi.fn()
})
}));
vi.mock('worker-timers', () => ({
setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeout: (id) => globalThis.clearTimeout(id)
}));
import { useGroupMembers } from '../useGroupMembers';
import { groupRequest } from '../../../../api';
import { groupDialogFilterOptions } from '../../../../shared/constants';
/**
*
* @param overrides
*/
function createGroupDialog(overrides = {}) {
return ref({
id: 'grp_1',
visible: true,
inGroup: false,
members: [],
memberSearch: '',
memberSearchResults: [],
memberSortOrder: { value: '' },
memberFilter: { id: null, name: 'Everyone' },
ref: { roles: [], memberCount: 0 },
...overrides
});
}
/**
*
* @param overrides
*/
function createDeps(overrides = {}) {
return {
currentUser: ref({ id: 'usr_me' }),
applyGroupMember: vi.fn((json) => json),
handleGroupMember: vi.fn(),
t: (key) => key,
...overrides
};
}
describe('useGroupMembers', () => {
beforeEach(() => {
vi.clearAllMocks();
groupRequest.getCachedGroupMembers.mockReset();
});
describe('groupDialogMemberSortValue', () => {
test('returns current sort order value', () => {
const groupDialog = createGroupDialog({
memberSortOrder: { value: 'joinedAt:desc', name: 'sort.joined' }
});
const { groupDialogMemberSortValue } = useGroupMembers(
groupDialog,
createDeps()
);
expect(groupDialogMemberSortValue.value).toBe('joinedAt:desc');
});
test('returns empty string when no sort order', () => {
const groupDialog = createGroupDialog({
memberSortOrder: {}
});
const { groupDialogMemberSortValue } = useGroupMembers(
groupDialog,
createDeps()
);
expect(groupDialogMemberSortValue.value).toBe('');
});
});
describe('groupDialogMemberFilterKey', () => {
test('returns everyone when filter id is null', () => {
const groupDialog = createGroupDialog({
memberFilter: { id: null }
});
const { groupDialogMemberFilterKey } = useGroupMembers(
groupDialog,
createDeps()
);
expect(groupDialogMemberFilterKey.value).toBe('everyone');
});
test('returns usersWithNoRole when filter id is empty string', () => {
const groupDialog = createGroupDialog({ memberFilter: { id: '' } });
const { groupDialogMemberFilterKey } = useGroupMembers(
groupDialog,
createDeps()
);
expect(groupDialogMemberFilterKey.value).toBe('usersWithNoRole');
});
test('returns role:id for role-based filters', () => {
const groupDialog = createGroupDialog({
memberFilter: { id: 'role_123' }
});
const { groupDialogMemberFilterKey } = useGroupMembers(
groupDialog,
createDeps()
);
expect(groupDialogMemberFilterKey.value).toBe('role:role_123');
});
test('returns null when no filter', () => {
const groupDialog = createGroupDialog({ memberFilter: null });
const { groupDialogMemberFilterKey } = useGroupMembers(
groupDialog,
createDeps()
);
expect(groupDialogMemberFilterKey.value).toBeNull();
});
});
describe('groupDialogMemberFilterGroups', () => {
test('includes filter options and role groups', () => {
const groupDialog = createGroupDialog({
ref: {
roles: [
{ id: 'role_1', name: 'Admin', defaultRole: false },
{ id: 'role_2', name: 'Member', defaultRole: true }
],
memberCount: 10
}
});
const { groupDialogMemberFilterGroups } = useGroupMembers(
groupDialog,
createDeps()
);
const groups = groupDialogMemberFilterGroups.value;
expect(groups.length).toBeGreaterThanOrEqual(1);
// should have a filters group
const filtersGroup = groups.find((g) => g.key === 'filters');
expect(filtersGroup).toBeDefined();
expect(filtersGroup.items.length).toBeGreaterThan(0);
});
test('excludes default roles from role items', () => {
const groupDialog = createGroupDialog({
ref: {
roles: [
{ id: 'role_1', name: 'Admin', defaultRole: false },
{ id: 'role_2', name: 'Default', defaultRole: true }
],
memberCount: 10
}
});
const { groupDialogMemberFilterGroups } = useGroupMembers(
groupDialog,
createDeps()
);
const rolesGroup = groupDialogMemberFilterGroups.value.find(
(g) => g.key === 'roles'
);
if (rolesGroup) {
expect(rolesGroup.items).toHaveLength(1);
expect(rolesGroup.items[0].label).toBe('Admin');
}
});
test('omits roles group when no non-default roles exist', () => {
const groupDialog = createGroupDialog({
ref: {
roles: [
{ id: 'role_1', name: 'Default', defaultRole: true }
],
memberCount: 10
}
});
const { groupDialogMemberFilterGroups } = useGroupMembers(
groupDialog,
createDeps()
);
const rolesGroup = groupDialogMemberFilterGroups.value.find(
(g) => g.key === 'roles'
);
expect(rolesGroup).toBeUndefined();
});
});
describe('groupMembersSearch', () => {
test('clears results when search is less than 3 characters', () => {
const groupDialog = createGroupDialog({ memberSearch: 'ab' });
const { groupMembersSearch, isGroupMembersLoading } =
useGroupMembers(groupDialog, createDeps());
groupMembersSearch();
expect(groupDialog.value.memberSearchResults).toEqual([]);
expect(isGroupMembersLoading.value).toBe(false);
});
test('calls API when search is 3 or more characters', async () => {
const groupDialog = createGroupDialog({ memberSearch: 'abc' });
groupRequest.getGroupMembersSearch.mockResolvedValue({
json: { results: [{ userId: 'usr_1' }] },
params: { groupId: 'grp_1' }
});
const deps = createDeps();
const { groupMembersSearch } = useGroupMembers(groupDialog, deps);
groupMembersSearch();
// wait for the debounced call
await vi.waitFor(() => {
expect(groupRequest.getGroupMembersSearch).toHaveBeenCalledWith(
{
groupId: 'grp_1',
query: 'abc',
n: 100,
offset: 0
}
);
});
});
});
describe('loadMoreGroupMembers', () => {
test('does not load when already done', async () => {
const groupDialog = createGroupDialog();
const { loadMoreGroupMembers, isGroupMembersDone } =
useGroupMembers(groupDialog, createDeps());
isGroupMembersDone.value = true;
await loadMoreGroupMembers();
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
});
test('does not load when already loading', async () => {
const groupDialog = createGroupDialog();
const { loadMoreGroupMembers, isGroupMembersLoading } =
useGroupMembers(groupDialog, createDeps());
isGroupMembersLoading.value = true;
await loadMoreGroupMembers();
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
});
test('marks done when fewer than n results returned', async () => {
const groupDialog = createGroupDialog();
groupRequest.getCachedGroupMembers.mockResolvedValue({
json: [{ userId: 'usr_1' }],
params: { groupId: 'grp_1', n: 100, offset: 0 }
});
const {
loadMoreGroupMembers,
isGroupMembersDone,
loadMoreGroupMembersParams
} = useGroupMembers(groupDialog, createDeps());
loadMoreGroupMembersParams.value = {
n: 100,
offset: 0,
groupId: 'grp_1',
sort: 'joinedAt:desc'
};
await loadMoreGroupMembers();
expect(isGroupMembersDone.value).toBe(true);
});
test('appends members to groupDialog.members', async () => {
const groupDialog = createGroupDialog({
members: [{ userId: 'existing' }]
});
groupRequest.getCachedGroupMembers.mockResolvedValue({
json: [{ userId: 'usr_new' }],
params: { groupId: 'grp_1', n: 100, offset: 0 }
});
const { loadMoreGroupMembers, loadMoreGroupMembersParams } =
useGroupMembers(groupDialog, createDeps());
loadMoreGroupMembersParams.value = {
n: 100,
offset: 0,
groupId: 'grp_1',
sort: 'joinedAt:desc'
};
await loadMoreGroupMembers();
expect(groupDialog.value.members).toHaveLength(2);
});
test('removes duplicate current user from first position', async () => {
const deps = createDeps();
const groupDialog = createGroupDialog({
members: [{ userId: 'usr_me' }]
});
groupRequest.getCachedGroupMembers.mockResolvedValue({
json: [{ userId: 'usr_me' }, { userId: 'usr_2' }],
params: { groupId: 'grp_1', n: 100, offset: 0 }
});
const { loadMoreGroupMembers, loadMoreGroupMembersParams } =
useGroupMembers(groupDialog, deps);
loadMoreGroupMembersParams.value = {
n: 100,
offset: 0,
groupId: 'grp_1',
sort: 'joinedAt:desc'
};
await loadMoreGroupMembers();
// duplicate at position 0 should be removed
const userIds = groupDialog.value.members.map((m) => m.userId);
expect(userIds).toEqual(['usr_me', 'usr_2']);
});
test('marks done on error', async () => {
const groupDialog = createGroupDialog();
groupRequest.getCachedGroupMembers.mockRejectedValue(
new Error('fail')
);
const {
loadMoreGroupMembers,
isGroupMembersDone,
loadMoreGroupMembersParams
} = useGroupMembers(groupDialog, createDeps());
loadMoreGroupMembersParams.value = {
n: 100,
offset: 0,
groupId: 'grp_1',
sort: 'joinedAt:desc'
};
await expect(loadMoreGroupMembers()).rejects.toThrow('fail');
expect(isGroupMembersDone.value).toBe(true);
});
});
describe('setGroupMemberSortOrder', () => {
test('does not reload when sort order unchanged', async () => {
const groupDialog = createGroupDialog({
memberSortOrder: { value: 'joinedAt:desc' }
});
const { setGroupMemberSortOrder } = useGroupMembers(
groupDialog,
createDeps()
);
await setGroupMemberSortOrder({ value: 'joinedAt:desc' });
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
});
});
describe('setGroupMemberFilter', () => {
test('does not reload when filter unchanged', async () => {
const { markRaw } = require('vue');
const filter = markRaw(groupDialogFilterOptions.everyone);
const groupDialog = createGroupDialog();
// Use markRaw to prevent Vue from wrapping the filter in a Proxy
groupDialog.value.memberFilter = filter;
const { setGroupMemberFilter } = useGroupMembers(
groupDialog,
createDeps()
);
await setGroupMemberFilter(filter);
expect(groupRequest.getCachedGroupMembers).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,436 @@
import { reactive, ref } from 'vue';
import { describe, expect, test, vi, beforeEach } from 'vitest';
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key
}),
createI18n: () => ({
global: { t: (key) => key },
install: vi.fn()
})
}));
vi.mock('../../../../plugin/router', () => {
const { ref: vRef } = require('vue');
return {
router: {
beforeEach: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
currentRoute: vRef({ path: '/', name: '', meta: {} }),
isReady: vi.fn().mockResolvedValue(true)
},
initRouter: vi.fn()
};
});
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
const { ref: vRef } = require('vue');
return {
...actual,
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
currentRoute: vRef({ path: '/', name: '', meta: {} })
}))
};
});
vi.mock('../../../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
vi.mock('../../../../service/database', () => ({
database: new Proxy(
{},
{
get: (_target, prop) => {
if (prop === '__esModule') return false;
return vi.fn().mockResolvedValue(null);
}
}
)
}));
vi.mock('../../../../service/config', () => ({
default: {
init: vi.fn(),
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
setString: vi.fn(),
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
setBool: vi.fn(),
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
setInt: vi.fn(),
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
setFloat: vi.fn(),
getObject: vi.fn().mockReturnValue(null),
setObject: vi.fn(),
getArray: vi.fn().mockReturnValue([]),
setArray: vi.fn(),
remove: vi.fn()
}
}));
vi.mock('../../../../service/jsonStorage', () => ({ default: vi.fn() }));
vi.mock('../../../../service/watchState', () => ({
watchState: { isLoggedIn: false }
}));
vi.mock('../../../../service/request', () => ({
request: vi.fn().mockResolvedValue({ json: {} }),
processBulk: vi.fn(),
buildRequestInit: vi.fn(),
parseResponse: vi.fn(),
shouldIgnoreError: vi.fn(),
$throw: vi.fn(),
failedGetRequests: new Map()
}));
vi.mock('../../../../api', () => ({
groupRequest: {},
userRequest: {}
}));
import { useGroupModerationData } from '../useGroupModerationData';
function createTables() {
return {
members: reactive({ data: [], pageSize: 15 }),
bans: reactive({ data: [], filters: [{ prop: ['$displayName'], value: '' }], pageSize: 15 }),
invites: reactive({ data: [], pageSize: 15 }),
joinRequests: reactive({ data: [], pageSize: 15 }),
blocked: reactive({ data: [], pageSize: 15 }),
logs: reactive({ data: [], filters: [{ prop: ['description'], value: '' }], pageSize: 15 })
};
}
function createDeps(overrides = {}) {
const tables = createTables();
return {
groupMemberModeration: ref({
id: 'grp_test',
visible: true,
groupRef: { memberCount: 10, roles: [] }
}),
currentUser: ref({ id: 'usr_self' }),
applyGroupMember: vi.fn((json) => json),
handleGroupMember: vi.fn(),
tables,
selection: {
selectedUsers: {},
setSelectedUsers: vi.fn()
},
groupRequest: {
getGroupBans: vi.fn(),
getGroupLogs: vi.fn(),
getGroupInvites: vi.fn(),
getGroupJoinRequests: vi.fn(),
getGroupMember: vi.fn(),
getGroupMembers: vi.fn(),
getGroupMembersSearch: vi.fn()
},
userRequest: {
getCachedUser: vi.fn()
},
...overrides
};
}
describe('useGroupModerationData', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getAllGroupBans', () => {
test('populates bans table with fetched data', async () => {
const deps = createDeps();
const bans = [
{ userId: 'usr_1', user: { displayName: 'Alice' } },
{ userId: 'usr_2', user: { displayName: 'Bob' } }
];
deps.groupRequest.getGroupBans.mockResolvedValue({
json: bans,
params: { groupId: 'grp_test' }
});
const { getAllGroupBans } = useGroupModerationData(deps);
await getAllGroupBans('grp_test');
expect(deps.tables.bans.data).toHaveLength(2);
expect(deps.groupRequest.getGroupBans).toHaveBeenCalledWith({
groupId: 'grp_test',
n: 100,
offset: 0
});
});
test('paginates through multiple pages', async () => {
const deps = createDeps();
const page1 = Array.from({ length: 100 }, (_, i) => ({
userId: `usr_${i}`,
user: { displayName: `User${i}` }
}));
const page2 = [{ userId: 'usr_100', user: { displayName: 'User100' } }];
deps.groupRequest.getGroupBans
.mockResolvedValueOnce({ json: page1, params: { groupId: 'grp_test' } })
.mockResolvedValueOnce({ json: page2, params: { groupId: 'grp_test' } });
const { getAllGroupBans } = useGroupModerationData(deps);
await getAllGroupBans('grp_test');
expect(deps.tables.bans.data).toHaveLength(101);
expect(deps.groupRequest.getGroupBans).toHaveBeenCalledTimes(2);
});
test('skips data from wrong group', async () => {
const deps = createDeps();
deps.groupRequest.getGroupBans.mockResolvedValue({
json: [{ userId: 'usr_1' }],
params: { groupId: 'grp_other' }
});
const { getAllGroupBans } = useGroupModerationData(deps);
await getAllGroupBans('grp_test');
// Should have continued past the mismatched group and eventually exhausted pages
// The data won't contain the mismatched entry because it was skipped
expect(deps.tables.bans.data).toHaveLength(0);
});
test('handles API error gracefully', async () => {
const { toast } = await import('vue-sonner');
const deps = createDeps();
deps.groupRequest.getGroupBans.mockRejectedValue(new Error('Network error'));
const { getAllGroupBans, isGroupMembersLoading } = useGroupModerationData(deps);
await getAllGroupBans('grp_test');
expect(toast.error).toHaveBeenCalledWith('Failed to get group bans');
expect(isGroupMembersLoading.value).toBe(false);
});
test('stops when dialog is no longer visible', async () => {
const deps = createDeps();
const page1 = Array.from({ length: 100 }, (_, i) => ({
userId: `usr_${i}`
}));
deps.groupRequest.getGroupBans
.mockResolvedValueOnce({ json: page1, params: { groupId: 'grp_test' } })
.mockImplementation(() => {
deps.groupMemberModeration.value.visible = false;
return Promise.resolve({ json: [{ userId: 'usr_extra' }], params: { groupId: 'grp_test' } });
});
const { getAllGroupBans } = useGroupModerationData(deps);
await getAllGroupBans('grp_test');
// Should stop after detecting visible=false
expect(deps.groupRequest.getGroupBans).toHaveBeenCalledTimes(2);
});
});
describe('getAllGroupLogs', () => {
test('populates logs table and deduplicates', async () => {
const deps = createDeps();
const logs = [
{ id: 'log_1', description: 'event 1' },
{ id: 'log_2', description: 'event 2' },
{ id: 'log_1', description: 'event 1 dup' }
];
deps.groupRequest.getGroupLogs.mockResolvedValue({
json: { results: logs, hasNext: false },
params: { groupId: 'grp_test' }
});
const { getAllGroupLogs } = useGroupModerationData(deps);
await getAllGroupLogs('grp_test');
expect(deps.tables.logs.data).toHaveLength(2);
});
test('passes eventTypes filter when provided', async () => {
const deps = createDeps();
deps.groupRequest.getGroupLogs.mockResolvedValue({
json: { results: [], hasNext: false },
params: { groupId: 'grp_test' }
});
const { getAllGroupLogs } = useGroupModerationData(deps);
await getAllGroupLogs('grp_test', ['group.member.ban', 'group.member.kick']);
expect(deps.groupRequest.getGroupLogs).toHaveBeenCalledWith(
expect.objectContaining({
eventTypes: ['group.member.ban', 'group.member.kick']
})
);
});
});
describe('getAllGroupInvitesAndJoinRequests', () => {
test('fetches invites, join requests, and blocked in parallel', async () => {
const deps = createDeps();
deps.groupRequest.getGroupInvites.mockResolvedValue({
json: [{ userId: 'usr_inv' }],
params: { groupId: 'grp_test' }
});
deps.groupRequest.getGroupJoinRequests
.mockResolvedValueOnce({
json: [{ userId: 'usr_join' }],
params: { groupId: 'grp_test' }
})
.mockResolvedValueOnce({
json: [{ userId: 'usr_block' }],
params: { groupId: 'grp_test' }
});
const { getAllGroupInvitesAndJoinRequests } = useGroupModerationData(deps);
await getAllGroupInvitesAndJoinRequests('grp_test');
expect(deps.tables.invites.data).toHaveLength(1);
expect(deps.tables.joinRequests.data).toHaveLength(1);
expect(deps.tables.blocked.data).toHaveLength(1);
});
});
describe('selectGroupMemberUserId', () => {
test('parses multiple user IDs from input', async () => {
const deps = createDeps();
deps.groupRequest.getGroupMember.mockResolvedValue({
json: { userId: 'usr_aaaa1111-2222-3333-4444-555566667777', user: { displayName: 'A' } },
params: {}
});
const { selectGroupMemberUserId } = useGroupModerationData(deps);
await selectGroupMemberUserId(
'usr_aaaa1111-2222-3333-4444-555566667777 usr_bbbb1111-2222-3333-4444-555566667777'
);
expect(deps.groupRequest.getGroupMember).toHaveBeenCalledTimes(2);
});
test('falls back to raw input when no usr_ pattern found', async () => {
const deps = createDeps();
deps.groupRequest.getGroupMember.mockResolvedValue({
json: { userId: 'some_input', user: { displayName: 'Test' } },
params: {}
});
const { selectGroupMemberUserId } = useGroupModerationData(deps);
await selectGroupMemberUserId('some_input');
expect(deps.groupRequest.getGroupMember).toHaveBeenCalledWith({
groupId: 'grp_test',
userId: 'some_input'
});
});
test('does nothing with empty input', async () => {
const deps = createDeps();
const { selectGroupMemberUserId } = useGroupModerationData(deps);
await selectGroupMemberUserId('');
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
});
});
describe('addGroupMemberToSelection', () => {
test('uses group member data when available', async () => {
const deps = createDeps();
const member = { userId: 'usr_1', user: { displayName: 'Alice' } };
deps.groupRequest.getGroupMember.mockResolvedValue({
json: member,
params: {}
});
deps.applyGroupMember.mockReturnValue(member);
const { addGroupMemberToSelection } = useGroupModerationData(deps);
await addGroupMemberToSelection('usr_1');
expect(deps.selection.setSelectedUsers).toHaveBeenCalledWith('usr_1', member);
expect(deps.userRequest.getCachedUser).not.toHaveBeenCalled();
});
test('falls back to user API when member has no user object', async () => {
const deps = createDeps();
deps.groupRequest.getGroupMember.mockResolvedValue({
json: { userId: 'usr_1' },
params: {}
});
deps.applyGroupMember.mockReturnValue({ userId: 'usr_1' });
deps.userRequest.getCachedUser.mockResolvedValue({
json: { id: 'usr_1', displayName: 'Alice' }
});
const { addGroupMemberToSelection } = useGroupModerationData(deps);
await addGroupMemberToSelection('usr_1');
expect(deps.userRequest.getCachedUser).toHaveBeenCalledWith({ userId: 'usr_1' });
expect(deps.selection.setSelectedUsers).toHaveBeenCalledWith('usr_1', expect.objectContaining({
userId: 'usr_1',
displayName: 'Alice'
}));
});
});
describe('resetData', () => {
test('clears all table data and search state', () => {
const deps = createDeps();
deps.tables.members.data = [{ userId: 'usr_1' }];
deps.tables.bans.data = [{ userId: 'usr_2' }];
const { resetData, memberSearch } = useGroupModerationData(deps);
memberSearch.value = 'test';
resetData();
expect(deps.tables.members.data).toHaveLength(0);
expect(deps.tables.bans.data).toHaveLength(0);
expect(deps.tables.invites.data).toHaveLength(0);
expect(deps.tables.joinRequests.data).toHaveLength(0);
expect(deps.tables.blocked.data).toHaveLength(0);
expect(deps.tables.logs.data).toHaveLength(0);
expect(memberSearch.value).toBe('');
});
});
describe('member search / sort / filter', () => {
test('groupMembersSearch clears table when search is too short', () => {
const deps = createDeps();
deps.tables.members.data = [{ userId: 'usr_1' }];
const { groupMembersSearch, memberSearch, isGroupMembersLoading } = useGroupModerationData(deps);
memberSearch.value = 'ab';
groupMembersSearch();
expect(deps.tables.members.data).toHaveLength(0);
expect(isGroupMembersLoading.value).toBe(false);
});
test('setGroupMemberSortOrder does nothing when sort is the same', async () => {
const deps = createDeps();
deps.groupRequest.getGroupMember.mockResolvedValue({ json: null, params: {} });
const { setGroupMemberSortOrder, memberSortOrder } = useGroupModerationData(deps);
const currentSort = memberSortOrder.value;
await setGroupMemberSortOrder(currentSort);
// getGroupMember should not have been called since sort didn't change
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
});
test('setGroupMemberFilter does nothing when filter is the same', async () => {
const deps = createDeps();
const { setGroupMemberFilter, memberFilter } = useGroupModerationData(deps);
const currentFilter = memberFilter.value;
await setGroupMemberFilter(currentFilter);
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
});
});
describe('loadAllGroupMembers', () => {
test('does nothing when already loading', async () => {
const deps = createDeps();
const { loadAllGroupMembers, isGroupMembersLoading } = useGroupModerationData(deps);
isGroupMembersLoading.value = true;
await loadAllGroupMembers();
expect(deps.groupRequest.getGroupMember).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,58 @@
import { computed } from 'vue';
/**
* Composable for filtering group calendar events into past and upcoming,
* and updating follow state on individual events.
*
* @param {import('vue').Ref} groupDialog - reactive ref to the group dialog state
* @returns {{
* pastCalenderEvents: import('vue').ComputedRef<Array>,
* upcomingCalenderEvents: import('vue').ComputedRef<Array>,
* updateFollowingCalendarData: (event: Object) => void
* }}
*/
export function useGroupCalendarEvents(groupDialog) {
const pastCalenderEvents = computed(() => {
if (!groupDialog.value.calendar) {
return [];
}
const now = Date.now();
return groupDialog.value.calendar.filter((event) => {
const eventEnd = new Date(event.endsAt).getTime();
return eventEnd < now;
});
});
const upcomingCalenderEvents = computed(() => {
if (!groupDialog.value.calendar) {
return [];
}
const now = Date.now();
return groupDialog.value.calendar.filter((event) => {
const eventEnd = new Date(event.endsAt).getTime();
return eventEnd >= now;
});
});
/**
* @param {Object} event
*/
function updateFollowingCalendarData(event) {
const calendar = groupDialog.value.calendar;
for (let i = 0; i < calendar.length; i++) {
if (calendar[i].id === event.id) {
calendar[i] = {
...calendar[i],
...event
};
break;
}
}
}
return {
pastCalenderEvents,
upcomingCalenderEvents,
updateFollowingCalendarData
};
}

View File

@@ -0,0 +1,114 @@
import { computed, ref } from 'vue';
import { groupRequest } from '../../../api';
/**
* Composable for managing group gallery loading and display state.
*
* @param {import('vue').Ref} groupDialog - reactive ref to the group dialog state
* @returns {{
* isGroupGalleryLoading: import('vue').Ref<boolean>,
* groupDialogGalleryCurrentName: import('vue').Ref<string>,
* groupGalleryTabs: import('vue').ComputedRef<Array>,
* groupGalleryStatus: (gallery: Object) => Object,
* getGroupGalleries: () => Promise<void>,
* getGroupGallery: (groupId: string, galleryId: string) => Promise<void>
* }}
*/
export function useGroupGalleries(groupDialog) {
const groupDialogGalleryCurrentName = ref('0');
const isGroupGalleryLoading = ref(false);
const groupGalleryTabs = computed(() =>
(groupDialog.value?.ref?.galleries || []).map((gallery, index) => ({
value: String(index),
label: gallery?.name ?? ''
}))
);
/**
* @param {Object} gallery
*/
function groupGalleryStatus(gallery) {
const style = {};
if (!gallery.membersOnly) {
style.blue = true;
} else if (!gallery.roleIdsToView) {
style.green = true;
} else {
style.red = true;
}
return style;
}
/**
*
*/
function updateGroupDialogData(obj) {
groupDialog.value = {
...groupDialog.value,
...obj
};
}
/**
*
*/
async function getGroupGalleries() {
updateGroupDialogData({ ...groupDialog.value, galleries: {} });
groupDialogGalleryCurrentName.value = '0';
isGroupGalleryLoading.value = true;
const groupId = groupDialog.value.id;
const tasks = (groupDialog.value.ref.galleries || []).map((gallery) =>
getGroupGallery(groupId, gallery.id)
);
await Promise.allSettled(tasks);
isGroupGalleryLoading.value = false;
}
/**
* @param {string} groupId
* @param {string} galleryId
*/
async function getGroupGallery(groupId, galleryId) {
try {
const params = {
groupId,
galleryId,
n: 100,
offset: 0
};
const count = 50; // 5000 max
for (let i = 0; i < count; i++) {
const args = await groupRequest.getCachedGroupGallery(params);
if (args) {
for (const json of args.json) {
if (groupDialog.value.id === json.groupId) {
if (!groupDialog.value.galleries[json.galleryId]) {
groupDialog.value.galleries[json.galleryId] =
[];
}
groupDialog.value.galleries[json.galleryId].push(
json
);
}
}
}
params.offset += 100;
if (args.json.length < 100) {
break;
}
}
} catch (err) {
console.error(err);
}
}
return {
isGroupGalleryLoading,
groupDialogGalleryCurrentName,
groupGalleryTabs,
groupGalleryStatus,
getGroupGalleries,
getGroupGallery
};
}

View File

@@ -0,0 +1,318 @@
import { computed, ref } from 'vue';
import { debounce } from '../../../shared/utils';
import {
groupDialogFilterOptions,
groupDialogSortingOptions
} from '../../../shared/constants';
import { groupRequest } from '../../../api';
import * as workerTimers from 'worker-timers';
/**
* Composable for managing group member loading, searching, sorting, and filtering.
*
* @param {import('vue').Ref} groupDialog - reactive ref to the group dialog state
* @param {Object} deps - external dependencies
* @param {import('vue').Ref} deps.currentUser - reactive ref to the current user
* @param {Function} deps.applyGroupMember - function to apply group member data
* @param {Function} deps.handleGroupMember - function to handle group member updates
* @param {Function} deps.t - i18n translation function
* @returns {Object} members composable API
*/
export function useGroupMembers(
groupDialog,
{ currentUser, applyGroupMember, handleGroupMember, t }
) {
const isGroupMembersDone = ref(false);
const isGroupMembersLoading = ref(false);
let loadMoreGroupMembersParams = ref({
n: 100,
offset: 0,
groupId: '',
sort: '',
roleId: ''
});
const groupDialogMemberSortValue = computed({
get() {
return groupDialog.value?.memberSortOrder?.value ?? '';
},
set(value) {
const option = Object.values(groupDialogSortingOptions).find(
(item) => item.value === value
);
if (option) {
setGroupMemberSortOrder(option);
}
}
});
const groupDialogMemberFilterKey = computed({
get() {
const filter = groupDialog.value?.memberFilter;
if (!filter) return null;
if (filter.id === null) return 'everyone';
if (filter.id === '') return 'usersWithNoRole';
return `role:${filter.id}`;
},
set(key) {
if (!key) return;
if (key === 'everyone') {
setGroupMemberFilter(groupDialogFilterOptions.everyone);
return;
}
if (key === 'usersWithNoRole') {
setGroupMemberFilter(groupDialogFilterOptions.usersWithNoRole);
return;
}
if (key.startsWith('role:')) {
const roleId = key.slice('role:'.length);
const role = groupDialog.value?.ref?.roles?.find(
(r) => r.id === roleId
);
if (role) {
setGroupMemberFilter(role);
}
}
}
});
const groupDialogMemberFilterGroups = computed(() => {
const filterItems = Object.values(groupDialogFilterOptions).map(
(item) => ({
value:
item.id === null
? 'everyone'
: item.id === ''
? 'usersWithNoRole'
: `role:${item.id}`,
label: t(item.name),
search: t(item.name)
})
);
const roleItems = (groupDialog.value?.ref?.roles ?? [])
.filter((role) => !role.defaultRole)
.map((role) => ({
value: `role:${role.id}`,
label: role.name,
search: role.name
}));
return [
{
key: 'filters',
label: t('dialog.group.members.filter'),
items: filterItems
},
{
key: 'roles',
label: 'Roles',
items: roleItems
}
].filter((group) => group.items.length);
});
/**
*
*/
function groupMembersSearch() {
if (groupDialog.value.memberSearch.length < 3) {
groupDialog.value.memberSearchResults = [];
isGroupMembersLoading.value = false;
return;
}
debounce(groupMembersSearchDebounced, 200)();
}
/**
*
*/
function groupMembersSearchDebounced() {
const D = groupDialog.value;
const search = D.memberSearch;
D.memberSearchResults = [];
if (!search || search.length < 3) {
return;
}
isGroupMembersLoading.value = true;
groupRequest
.getGroupMembersSearch({
groupId: D.id,
query: search,
n: 100,
offset: 0
})
.then((args) => {
for (const json of args.json.results) {
handleGroupMember({
json,
params: {
groupId: args.params.groupId
}
});
}
if (D.id === args.params.groupId) {
D.memberSearchResults = args.json.results;
}
})
.finally(() => {
isGroupMembersLoading.value = false;
});
}
/**
*
*/
async function getGroupDialogGroupMembers() {
const D = groupDialog.value;
D.members = [];
isGroupMembersDone.value = false;
loadMoreGroupMembersParams.value = {
sort: 'joinedAt:desc',
roleId: '',
n: 100,
offset: 0,
groupId: D.id
};
if (D.memberSortOrder.value) {
loadMoreGroupMembersParams.value.sort = D.memberSortOrder.value;
}
if (D.memberFilter.id !== null) {
loadMoreGroupMembersParams.value.roleId = D.memberFilter.id;
}
if (D.inGroup) {
await groupRequest
.getCachedGroupMember({
groupId: D.id,
userId: currentUser.value.id
})
.then((args) => {
args.ref = applyGroupMember(args.json);
if (args.json) {
args.json.user = currentUser.value;
if (D.memberFilter.id === null) {
// when flitered by role don't include self
D.members.push(args.json);
}
}
return args;
});
}
await loadMoreGroupMembers();
}
/**
*
*/
async function loadMoreGroupMembers() {
if (isGroupMembersDone.value || isGroupMembersLoading.value) {
return;
}
const D = groupDialog.value;
const params = loadMoreGroupMembersParams.value;
if (params.roleId === '') {
delete params.roleId;
}
D.memberSearch = '';
isGroupMembersLoading.value = true;
await groupRequest
.getCachedGroupMembers(params)
.finally(() => {
isGroupMembersLoading.value = false;
})
.then((args) => {
for (const json of args.json) {
handleGroupMember({
json,
params: {
groupId: args.params.groupId
}
});
}
for (let i = 0; i < args.json.length; i++) {
const member = args.json[i];
if (member.userId === currentUser.value.id) {
if (
D.members.length > 0 &&
D.members[0].userId === currentUser.value.id
) {
// remove duplicate and keep sort order
D.members.splice(0, 1);
}
break;
}
}
if (args.json.length < params.n) {
isGroupMembersDone.value = true;
}
D.members = [...D.members, ...args.json];
params.offset += params.n;
return args;
})
.catch((err) => {
isGroupMembersDone.value = true;
throw err;
});
}
/**
*
*/
async function loadAllGroupMembers() {
if (isGroupMembersLoading.value) {
return;
}
await getGroupDialogGroupMembers();
while (groupDialog.value.visible && !isGroupMembersDone.value) {
isGroupMembersLoading.value = true;
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
isGroupMembersLoading.value = false;
await loadMoreGroupMembers();
}
}
/**
* @param {Object} sortOrder
*/
async function setGroupMemberSortOrder(sortOrder) {
const D = groupDialog.value;
if (D.memberSortOrder?.value === sortOrder?.value) {
return;
}
D.memberSortOrder = sortOrder;
await getGroupDialogGroupMembers();
}
/**
* @param {Object} filter
*/
async function setGroupMemberFilter(filter) {
const D = groupDialog.value;
if (D.memberFilter === filter) {
return;
}
D.memberFilter = filter;
await getGroupDialogGroupMembers();
}
return {
isGroupMembersDone,
isGroupMembersLoading,
loadMoreGroupMembersParams,
groupDialogMemberSortValue,
groupDialogMemberFilterKey,
groupDialogMemberFilterGroups,
groupMembersSearch,
getGroupDialogGroupMembers,
loadMoreGroupMembers,
loadAllGroupMembers,
setGroupMemberSortOrder,
setGroupMemberFilter
};
}

View File

@@ -0,0 +1,485 @@
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { debounce } from '../../../shared/utils';
import * as workerTimers from 'worker-timers';
/**
* Composable for group moderation data fetching, member management,
* searching, sorting and filtering.
*
* @param {object} deps
* @param {import('vue').Ref} deps.groupMemberModeration - store ref
* @param {import('vue').Ref} deps.currentUser - store ref
* @param {Function} deps.applyGroupMember - store action
* @param {Function} deps.handleGroupMember - store action
* @param {object} deps.tables - reactive table data objects
* @param {object} deps.tables.members
* @param {object} deps.tables.bans
* @param {object} deps.tables.invites
* @param {object} deps.tables.joinRequests
* @param {object} deps.tables.blocked
* @param {object} deps.tables.logs
* @param {object} deps.selection - from useGroupModerationSelection
* @param {object} deps.selection.selectedUsers
* @param {Function} deps.selection.setSelectedUsers
* @param {object} deps.groupRequest - API module
* @param {object} deps.userRequest - API module
*/
export function useGroupModerationData(deps) {
const {
groupMemberModeration,
currentUser,
applyGroupMember,
handleGroupMember,
tables,
selection,
groupRequest,
userRequest
} = deps;
const isGroupMembersLoading = ref(false);
const isGroupMembersDone = ref(false);
const memberFilter = ref({
id: null,
name: 'dialog.group.members.filters.everyone'
});
const memberSortOrder = ref({
id: '',
name: 'dialog.group.members.sorting.joined_at_desc',
value: 'joinedAt:desc'
});
const memberSearch = ref('');
const members = ref([]);
const loadMoreGroupMembersParams = ref({
n: 100,
offset: 0,
groupId: '',
sort: 'joinedAt:desc',
roleId: ''
});
// ── Members ──────────────────────────────────────────────────
async function getGroupMembers() {
members.value = [];
isGroupMembersDone.value = false;
loadMoreGroupMembersParams.value = {
sort: 'joinedAt:desc',
roleId: '',
n: 100,
offset: 0,
groupId: groupMemberModeration.value.id
};
if (memberSortOrder.value.value) {
loadMoreGroupMembersParams.value.sort = memberSortOrder.value.value;
}
if (memberFilter.value.id !== null) {
loadMoreGroupMembersParams.value.roleId = memberFilter.value.id;
}
await groupRequest
.getGroupMember({
groupId: groupMemberModeration.value.id,
userId: currentUser.value.id
})
.then((args) => {
args.ref = applyGroupMember(args.json);
if (args.json) {
args.json.user = currentUser.value;
if (memberFilter.value.id === null) {
members.value.push(args.json);
}
}
return args;
});
await loadMoreGroupMembers();
}
async function loadMoreGroupMembers() {
if (isGroupMembersDone.value || isGroupMembersLoading.value) {
return;
}
const params = loadMoreGroupMembersParams.value;
if (params.roleId === '') {
delete params.roleId;
}
memberSearch.value = '';
isGroupMembersLoading.value = true;
await groupRequest
.getGroupMembers(params)
.finally(() => {
isGroupMembersLoading.value = false;
})
.then((args) => {
for (const json of args.json) {
handleGroupMember({
json,
params: { groupId: args.params.groupId }
});
}
for (let i = 0; i < args.json.length; i++) {
const member = args.json[i];
if (member.userId === currentUser.value.id) {
if (members.value.length > 0 && members.value[0].userId === currentUser.value.id) {
members.value.splice(0, 1);
}
break;
}
}
if (args.json.length < params.n) {
isGroupMembersDone.value = true;
}
members.value = [...members.value, ...args.json];
tables.members.data = members.value.map((member) => ({
...member,
$selected: Boolean(selection.selectedUsers[member.userId])
}));
params.offset += params.n;
return args;
})
.catch((err) => {
isGroupMembersDone.value = true;
throw err;
});
}
async function loadAllGroupMembers() {
if (isGroupMembersLoading.value) {
return;
}
await getGroupMembers();
while (groupMemberModeration.value.visible && !isGroupMembersDone.value) {
isGroupMembersLoading.value = true;
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
isGroupMembersLoading.value = false;
await loadMoreGroupMembers();
}
}
async function setGroupMemberSortOrder(sortOrder) {
if (memberSortOrder.value === sortOrder) {
return;
}
memberSortOrder.value = sortOrder;
await getGroupMembers();
}
async function setGroupMemberFilter(filter) {
if (memberFilter.value === filter) {
return;
}
memberFilter.value = filter;
await getGroupMembers();
}
function groupMembersSearch() {
if (memberSearch.value.length < 3) {
tables.members.data = [];
isGroupMembersLoading.value = false;
return;
}
isGroupMembersLoading.value = true;
debounce(groupMembersSearchDebounced, 200)();
}
function groupMembersSearchDebounced() {
const groupId = groupMemberModeration.value.id;
const search = memberSearch.value;
tables.members.data = [];
if (memberSearch.value.length < 3) {
return;
}
isGroupMembersLoading.value = true;
groupRequest
.getGroupMembersSearch({
groupId,
query: search,
n: 100,
offset: 0
})
.then((args) => {
for (const json of args.json.results) {
handleGroupMember({
json,
params: { groupId: args.params.groupId }
});
}
if (groupId === args.params.groupId) {
tables.members.data = args.json.results.map((member) => ({
...member,
$selected: Boolean(selection.selectedUsers[member.userId])
}));
}
})
.finally(() => {
isGroupMembersLoading.value = false;
});
}
// ── Bans ─────────────────────────────────────────────────────
async function getAllGroupBans(groupId) {
tables.bans.data = [];
const params = { groupId, n: 100, offset: 0 };
const count = 50; // 5000 max
isGroupMembersLoading.value = true;
const fetchedBans = [];
try {
for (let i = 0; i < count; i++) {
const args = await groupRequest.getGroupBans(params);
if (args && args.json) {
if (groupMemberModeration.value.id !== args.params.groupId) {
continue;
}
args.json.forEach((json) => {
const ref = applyGroupMember(json);
fetchedBans.push(ref);
});
if (args.json.length < params.n) {
break;
}
params.offset += params.n;
} else {
break;
}
if (!groupMemberModeration.value.visible) {
break;
}
}
tables.bans.data = fetchedBans;
} catch {
toast.error('Failed to get group bans');
} finally {
isGroupMembersLoading.value = false;
}
}
// ── Invites / Join Requests / Blocked ────────────────────────
async function getAllGroupInvites(groupId) {
tables.invites.data = [];
const params = { groupId, n: 100, offset: 0 };
const count = 50; // 5000 max
isGroupMembersLoading.value = true;
let newData = [];
try {
for (let i = 0; i < count; i++) {
const args = await groupRequest.getGroupInvites(params);
if (args) {
if (groupMemberModeration.value.id !== args.params.groupId) {
return;
}
for (const json of args.json) {
const ref = applyGroupMember(json);
newData.push(ref);
}
}
params.offset += params.n;
if (args.json.length < params.n) {
break;
}
if (!groupMemberModeration.value.visible) {
break;
}
}
tables.invites.data = newData;
} catch {
toast.error('Failed to get group invites');
} finally {
isGroupMembersLoading.value = false;
}
}
async function getAllGroupJoinRequests(groupId) {
tables.joinRequests.data = [];
const params = { groupId, n: 100, offset: 0, blocked: false };
const count = 50; // 5000 max
isGroupMembersLoading.value = true;
let newData = [];
try {
for (let i = 0; i < count; i++) {
const args = await groupRequest.getGroupJoinRequests(params);
if (groupMemberModeration.value.id !== args.params.groupId) {
return;
}
for (const json of args.json) {
const ref = applyGroupMember(json);
newData.push(ref);
}
params.offset += params.n;
if (args.json.length < params.n) {
break;
}
if (!groupMemberModeration.value.visible) {
break;
}
}
tables.joinRequests.data = newData;
} catch {
toast.error('Failed to get group join requests');
} finally {
isGroupMembersLoading.value = false;
}
}
async function getAllGroupBlockedRequests(groupId) {
tables.blocked.data = [];
const params = { groupId, n: 100, offset: 0, blocked: true };
const count = 50; // 5000
isGroupMembersLoading.value = true;
let newData = [];
try {
for (let i = 0; i < count; i++) {
const args = await groupRequest.getGroupJoinRequests(params);
if (groupMemberModeration.value.id !== args.params.groupId) {
return;
}
for (const json of args.json) {
const ref = applyGroupMember(json);
newData.push(ref);
}
params.offset += params.n;
if (args.json.length < params.n) {
break;
}
if (!groupMemberModeration.value.visible) {
break;
}
}
tables.blocked.data = newData;
} catch {
toast.error('Failed to get group join requests');
} finally {
isGroupMembersLoading.value = false;
}
}
async function getAllGroupInvitesAndJoinRequests(groupId) {
try {
await Promise.all([
getAllGroupInvites(groupId),
getAllGroupJoinRequests(groupId),
getAllGroupBlockedRequests(groupId)
]);
} catch (error) {
console.error('Error fetching group invites/requests:', error);
}
}
// ── Logs ─────────────────────────────────────────────────────
async function getAllGroupLogs(groupId, eventTypes = []) {
tables.logs.data = [];
const params = { groupId, n: 100, offset: 0 };
if (eventTypes.length) {
params.eventTypes = eventTypes;
}
const count = 50; // 5000 max
isGroupMembersLoading.value = true;
let newData = [];
try {
for (let i = 0; i < count; i++) {
const args = await groupRequest.getGroupLogs(params);
if (args) {
if (groupMemberModeration.value.id !== args.params.groupId) {
continue;
}
for (const json of args.json.results) {
const existsInData = newData.some((dataItem) => dataItem.id === json.id);
if (!existsInData) {
newData.push(json);
}
}
}
params.offset += params.n;
if (!args.json.hasNext) {
break;
}
if (!groupMemberModeration.value.visible) {
break;
}
}
tables.logs.data = newData;
} catch {
toast.error('Failed to get group logs');
} finally {
isGroupMembersLoading.value = false;
}
}
// ── User Selection ───────────────────────────────────────────
async function addGroupMemberToSelection(userId) {
const D = groupMemberModeration.value;
let member = {};
const memberArgs = await groupRequest.getGroupMember({ groupId: D.id, userId });
if (memberArgs && memberArgs.json) {
member = applyGroupMember(memberArgs.json);
}
if (member && member.user) {
selection.setSelectedUsers(member.userId, member);
return;
}
const userArgs = await userRequest.getCachedUser({ userId });
member.userId = userArgs.json.id;
member.user = userArgs.json;
member.displayName = userArgs.json.displayName;
selection.setSelectedUsers(member.userId, member);
}
async function selectGroupMemberUserId(userIdInput) {
if (!userIdInput) return;
const regexUserId = /usr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
let match;
const userIdList = new Set();
while ((match = regexUserId.exec(userIdInput)) !== null) {
userIdList.add(match[0]);
}
if (userIdList.size === 0) {
userIdList.add(userIdInput);
}
const promises = [];
userIdList.forEach((userId) => {
promises.push(addGroupMemberToSelection(userId));
});
await Promise.allSettled(promises);
}
// ── Reset ────────────────────────────────────────────────────
function resetData() {
tables.members.data = [];
tables.bans.data = [];
tables.invites.data = [];
tables.joinRequests.data = [];
tables.blocked.data = [];
tables.logs.data = [];
memberSearch.value = '';
members.value = [];
isGroupMembersDone.value = false;
}
return {
isGroupMembersLoading,
isGroupMembersDone,
memberFilter,
memberSortOrder,
memberSearch,
members,
loadAllGroupMembers,
getGroupMembers,
setGroupMemberSortOrder,
setGroupMemberFilter,
groupMembersSearch,
selectGroupMemberUserId,
addGroupMemberToSelection,
getAllGroupBans,
getAllGroupLogs,
getAllGroupInvites,
getAllGroupJoinRequests,
getAllGroupBlockedRequests,
getAllGroupInvitesAndJoinRequests,
resetData
};
}