mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-18 06:13:52 +02:00
split user dialog
This commit is contained in:
File diff suppressed because it is too large
Load Diff
212
src/components/dialogs/UserDialog/UserDialogAvatarsTab.vue
Normal file
212
src/components/dialogs/UserDialog/UserDialogAvatarsTab.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<DeprecationAlert v-if="userDialog.ref.id === currentUser.id" :feature-name="t('nav_tooltip.my_avatars')" />
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button
|
||||
v-if="userDialog.ref.id === currentUser.id"
|
||||
class="rounded-full"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="userDialog.isAvatarsLoading"
|
||||
@click="refreshUserDialogAvatars()">
|
||||
<Spinner v-if="userDialog.isAvatarsLoading" />
|
||||
<RefreshCw v-else />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
class="rounded-full"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="userDialog.isAvatarsLoading"
|
||||
@click="setUserDialogAvatarsRemote(userDialog.id)">
|
||||
<Spinner v-if="userDialog.isAvatarsLoading" />
|
||||
<RefreshCw v-else />
|
||||
</Button>
|
||||
<span style="margin-left: 6px">{{
|
||||
t('dialog.user.avatars.total_count', { count: userDialogAvatars.length })
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<template v-if="userDialog.ref.id === currentUser.id">
|
||||
<Input v-model="avatarSearchQuery" class="h-8 w-40 mr-2" placeholder="Search avatars" @click.stop />
|
||||
<span class="mr-1">{{ t('dialog.user.avatars.sort_by') }}</span>
|
||||
<Select
|
||||
:model-value="userDialog.avatarSorting"
|
||||
:disabled="userDialog.isWorldsLoading"
|
||||
@update:modelValue="changeUserDialogAvatarSorting">
|
||||
<SelectTrigger size="sm" @click.stop>
|
||||
<SelectValue :placeholder="t(`dialog.user.avatars.sort_by_${userDialog.avatarSorting}`)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">{{ t('dialog.user.avatars.sort_by_name') }}</SelectItem>
|
||||
<SelectItem value="update">{{ t('dialog.user.avatars.sort_by_update') }}</SelectItem>
|
||||
<SelectItem value="createdAt">{{ t('dialog.user.avatars.sort_by_uploaded') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="ml-2 mr-1">{{ t('dialog.user.avatars.group_by') }}</span>
|
||||
<Select
|
||||
:model-value="userDialog.avatarReleaseStatus"
|
||||
:disabled="userDialog.isWorldsLoading"
|
||||
@update:modelValue="(value) => (userDialog.avatarReleaseStatus = value)">
|
||||
<SelectTrigger size="sm" @click.stop>
|
||||
<SelectValue :placeholder="t(`dialog.user.avatars.${userDialog.avatarReleaseStatus}`)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t('dialog.user.avatars.all') }}</SelectItem>
|
||||
<SelectItem value="public">{{ t('dialog.user.avatars.public') }}</SelectItem>
|
||||
<SelectItem value="private">{{ t('dialog.user.avatars.private') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; min-height: 60px; max-height: 50vh; overflow: auto">
|
||||
<template v-if="filteredUserDialogAvatars.length">
|
||||
<div
|
||||
v-for="avatar in filteredUserDialogAvatars"
|
||||
:key="avatar.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showAvatarDialog(avatar.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<img
|
||||
v-if="avatar.thumbnailImageUrl"
|
||||
class="size-full rounded-full object-cover"
|
||||
:src="avatar.thumbnailImageUrl"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="avatar.name"></span>
|
||||
<span
|
||||
v-if="avatar.releaseStatus === 'public'"
|
||||
class="block truncate text-xs"
|
||||
v-text="avatar.releaseStatus">
|
||||
</span>
|
||||
<span
|
||||
v-else-if="avatar.releaseStatus === 'private'"
|
||||
class="block truncate text-xs"
|
||||
v-text="avatar.releaseStatus">
|
||||
</span>
|
||||
<span v-else class="block truncate text-xs" v-text="avatar.releaseStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="!userDialog.isAvatarsLoading"
|
||||
style="display: flex; justify-content: center; align-items: center; min-height: 120px; width: 100%">
|
||||
<DataTableEmpty type="nodata" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { RefreshCw } from 'lucide-vue-next';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import DeprecationAlert from '@/components/DeprecationAlert.vue';
|
||||
|
||||
import { useAdvancedSettingsStore, useAvatarStore, useUserStore } from '../../../stores';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userDialog, currentUser } = storeToRefs(userStore);
|
||||
const { sortUserDialogAvatars, refreshUserDialogAvatars } = userStore;
|
||||
|
||||
const { showAvatarDialog, lookupAvatars } = useAvatarStore();
|
||||
const { cachedAvatars } = useAvatarStore();
|
||||
|
||||
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
|
||||
|
||||
const userDialogAvatars = computed(() => {
|
||||
const { avatars, avatarReleaseStatus } = userDialog.value;
|
||||
if (avatarReleaseStatus === 'public' || avatarReleaseStatus === 'private') {
|
||||
return avatars.filter((avatar) => avatar.releaseStatus === avatarReleaseStatus);
|
||||
}
|
||||
return avatars;
|
||||
});
|
||||
const avatarSearchQuery = ref('');
|
||||
const filteredUserDialogAvatars = computed(() => {
|
||||
const avatars = userDialogAvatars.value;
|
||||
if (userDialog.value.ref?.id !== currentUser.value.id) {
|
||||
return avatars;
|
||||
}
|
||||
const query = avatarSearchQuery.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return avatars;
|
||||
}
|
||||
return avatars.filter((avatar) => (avatar.name || '').toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => userDialog.value.id,
|
||||
() => {
|
||||
avatarSearchQuery.value = '';
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
function setUserDialogAvatars(userId) {
|
||||
const avatars = new Set();
|
||||
userDialogAvatars.value.forEach((avatar) => {
|
||||
avatars.add(avatar.id);
|
||||
});
|
||||
for (const ref of cachedAvatars.values()) {
|
||||
if (ref.authorId === userId && !avatars.has(ref.id)) {
|
||||
userDialog.value.avatars.push(ref);
|
||||
}
|
||||
}
|
||||
sortUserDialogAvatars(userDialog.value.avatars);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
async function setUserDialogAvatarsRemote(userId) {
|
||||
if (avatarRemoteDatabase.value && userId !== currentUser.value.id) {
|
||||
userDialog.value.isAvatarsLoading = true;
|
||||
const data = await lookupAvatars('authorId', userId);
|
||||
const avatars = new Set();
|
||||
userDialogAvatars.value.forEach((avatar) => {
|
||||
avatars.add(avatar.id);
|
||||
});
|
||||
if (data && typeof data === 'object') {
|
||||
data.forEach((avatar) => {
|
||||
if (avatar.id && !avatars.has(avatar.id)) {
|
||||
if (avatar.authorId === userId) {
|
||||
userDialog.value.avatars.push(avatar);
|
||||
} else {
|
||||
console.error(`Avatar authorId mismatch for ${avatar.id} - ${avatar.name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
userDialog.value.avatarSorting = 'name';
|
||||
userDialog.value.avatarReleaseStatus = 'all';
|
||||
userDialog.value.isAvatarsLoading = false;
|
||||
}
|
||||
sortUserDialogAvatars(userDialog.value.avatars);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param sortOption
|
||||
*/
|
||||
function changeUserDialogAvatarSorting(sortOption) {
|
||||
const D = userDialog.value;
|
||||
D.avatarSorting = sortOption;
|
||||
sortUserDialogAvatars(D.avatars);
|
||||
}
|
||||
|
||||
defineExpose({ setUserDialogAvatars, setUserDialogAvatarsRemote });
|
||||
</script>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<!-- <Button
|
||||
variant="outline"
|
||||
v-if="userFavoriteWorlds && userFavoriteWorlds.length > 0"
|
||||
type="default"
|
||||
:loading="userDialog.isFavoriteWorldsLoading"
|
||||
size="small"
|
||||
:icon="RefreshCw"
|
||||
circle
|
||||
style="position: absolute; right: 15px; bottom: 15px; z-index: 99"
|
||||
@click="getUserFavoriteWorlds(userDialog.id)">
|
||||
</Button> -->
|
||||
<template v-if="userDialog.userFavoriteWorlds && userDialog.userFavoriteWorlds.length > 0">
|
||||
<DeprecationAlert
|
||||
v-if="userDialog.ref.id === currentUser.id"
|
||||
:feature-name="t('nav_tooltip.favorite_worlds')" />
|
||||
<TabsUnderline
|
||||
v-model="favoriteWorldsTab"
|
||||
:items="favoriteWorldTabs"
|
||||
:unmount-on-hide="false"
|
||||
class="zero-margin-tabs"
|
||||
style="margin-top: 8px; height: 50vh">
|
||||
<template
|
||||
v-for="(list, index) in userDialog.userFavoriteWorlds"
|
||||
:key="`favorite-worlds-label-${index}`"
|
||||
v-slot:[`label-${index}`]>
|
||||
<span>
|
||||
<i class="x-status-icon" style="margin-right: 8px" :class="userFavoriteWorldsStatus(list[1])"> </i>
|
||||
<span style="font-weight: bold; font-size: 14px" v-text="list[0]"></span>
|
||||
<span style="font-size: 10px; margin-left: 6px"
|
||||
>{{ list[2].length }}/{{ favoriteLimits.maxFavoritesPerGroup.world }}</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
<template
|
||||
v-for="(list, index) in userDialog.userFavoriteWorlds"
|
||||
:key="`favorite-worlds-content-${index}`"
|
||||
v-slot:[String(index)]>
|
||||
<div
|
||||
class="flex flex-wrap items-start"
|
||||
style="margin-top: 8px; margin-bottom: 16px; min-height: 60px; max-height: none">
|
||||
<div
|
||||
v-for="world in list[2]"
|
||||
:key="world.favoriteId"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showWorldDialog(world.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<img
|
||||
class="size-full rounded-full object-cover"
|
||||
:src="world.thumbnailImageUrl"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="world.name"></span>
|
||||
<span v-if="world.occupants" class="block truncate text-xs">({{ world.occupants }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TabsUnderline>
|
||||
</template>
|
||||
<template v-else-if="!userDialog.isFavoriteWorldsLoading">
|
||||
<div style="display: flex; justify-content: center; align-items: center; height: 100%">
|
||||
<DataTableEmpty type="nodata" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||
import { TabsUnderline } from '@/components/ui/tabs';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import DeprecationAlert from '@/components/DeprecationAlert.vue';
|
||||
|
||||
import { useFavoriteStore, useUserStore, useWorldStore } from '../../../stores';
|
||||
import { favoriteRequest } from '../../../api';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { userDialog, currentUser } = storeToRefs(useUserStore());
|
||||
const { favoriteLimits } = storeToRefs(useFavoriteStore());
|
||||
const { handleFavoriteWorldList } = useFavoriteStore();
|
||||
const { showWorldDialog } = useWorldStore();
|
||||
|
||||
const favoriteWorldsTab = ref('0');
|
||||
const userDialogFavoriteWorldsRequestId = ref(0);
|
||||
|
||||
const favoriteWorldTabs = computed(() =>
|
||||
(userDialog.value.userFavoriteWorlds || []).map((list, index) => ({
|
||||
value: String(index),
|
||||
label: list?.[0] ?? ''
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param visibility
|
||||
*/
|
||||
function userFavoriteWorldsStatus(visibility) {
|
||||
const style = {};
|
||||
if (visibility === 'public') {
|
||||
style.green = true;
|
||||
} else if (visibility === 'friends') {
|
||||
style.blue = true;
|
||||
} else {
|
||||
style.red = true;
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
async function getUserFavoriteWorlds(userId) {
|
||||
const requestId = ++userDialogFavoriteWorldsRequestId.value;
|
||||
userDialog.value.isFavoriteWorldsLoading = true;
|
||||
favoriteWorldsTab.value = '0';
|
||||
userDialog.value.userFavoriteWorlds = [];
|
||||
const worldLists = [];
|
||||
const groupArgs = await favoriteRequest.getCachedFavoriteGroups({
|
||||
ownerId: userId,
|
||||
n: 100,
|
||||
offset: 0
|
||||
});
|
||||
if (requestId !== userDialogFavoriteWorldsRequestId.value || userDialog.value.id !== userId) {
|
||||
if (requestId === userDialogFavoriteWorldsRequestId.value) {
|
||||
userDialog.value.isFavoriteWorldsLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const worldGroups = groupArgs.json.filter((list) => list.type === 'world');
|
||||
const tasks = worldGroups.map(async (list) => {
|
||||
if (list.type !== 'world') {
|
||||
return null;
|
||||
}
|
||||
const params = {
|
||||
ownerId: userId,
|
||||
n: 100,
|
||||
offset: 0,
|
||||
userId,
|
||||
tag: list.name
|
||||
};
|
||||
try {
|
||||
const args = await favoriteRequest.getCachedFavoriteWorlds(params);
|
||||
handleFavoriteWorldList(args);
|
||||
return [list.displayName, list.visibility, args.json];
|
||||
} catch (err) {
|
||||
console.error('getUserFavoriteWorlds', err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const results = await Promise.all(tasks);
|
||||
for (const result of results) {
|
||||
if (result) {
|
||||
worldLists.push(result);
|
||||
}
|
||||
}
|
||||
if (requestId === userDialogFavoriteWorldsRequestId.value) {
|
||||
if (userDialog.value.id === userId) {
|
||||
userDialog.value.userFavoriteWorlds = worldLists;
|
||||
}
|
||||
userDialog.value.isFavoriteWorldsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ getUserFavoriteWorlds });
|
||||
</script>
|
||||
702
src/components/dialogs/UserDialog/UserDialogGroupsTab.vue
Normal file
702
src/components/dialogs/UserDialog/UserDialogGroupsTab.vue
Normal file
@@ -0,0 +1,702 @@
|
||||
<template>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="userDialog.isGroupsLoading"
|
||||
@click="getUserGroups(userDialog.id)">
|
||||
<Spinner v-if="userDialog.isGroupsLoading" />
|
||||
<RefreshCw v-else />
|
||||
</Button>
|
||||
<span style="margin-left: 6px">{{
|
||||
t('dialog.user.groups.total_count', { count: userDialog.userGroups.groups.length })
|
||||
}}</span>
|
||||
<template v-if="userDialogGroupEditMode">
|
||||
<span
|
||||
style="
|
||||
margin-left: 8px;
|
||||
|
||||
font-size: 10px;
|
||||
"
|
||||
>{{ t('dialog.user.groups.hold_shift') }}</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<template v-if="!userDialogGroupEditMode">
|
||||
<span style="margin-right: 6px">{{ t('dialog.user.groups.sort_by') }}</span>
|
||||
<Select
|
||||
:model-value="userDialogGroupSortingKey"
|
||||
:disabled="userDialog.isGroupsLoading"
|
||||
@update:modelValue="setUserDialogGroupSortingByKey">
|
||||
<SelectTrigger size="sm" @click.stop>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="(item, key) in userDialogGroupSortingOptions"
|
||||
:key="String(key)"
|
||||
:value="String(key)"
|
||||
:disabled="
|
||||
item === userDialogGroupSortingOptions.inGame && userDialog.id !== currentUser.id
|
||||
">
|
||||
{{ t(item.name) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
<Button variant="outline" size="sm" v-if="userDialogGroupEditMode" @click="exitEditModeCurrentUserGroups">
|
||||
{{ t('dialog.user.groups.exit_edit_mode') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
v-else-if="currentUser.id === userDialog.id"
|
||||
class="ml-2"
|
||||
@click="editModeCurrentUserGroups">
|
||||
{{ t('dialog.user.groups.edit_mode') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 8px">
|
||||
<template v-if="userDialogGroupEditMode">
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; margin-bottom: 16px; max-height: unset">
|
||||
<!-- Bulk actions dropdown (shown only in edit mode) -->
|
||||
<Select :model-value="bulkGroupActionValue" @update:modelValue="handleBulkGroupAction">
|
||||
<SelectTrigger size="sm" style="margin-right: 6px; margin-bottom: 6px" @click.stop>
|
||||
<SelectValue :placeholder="t('dialog.group.actions.manage_selected')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="visibility:visible">
|
||||
{{ t('dialog.group.actions.visibility_everyone') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="visibility:friends">
|
||||
{{ t('dialog.group.actions.visibility_friends') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="visibility:hidden">
|
||||
{{ t('dialog.group.actions.visibility_hidden') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="leave">
|
||||
{{ t('dialog.user.groups.leave_group_tooltip') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Select All button -->
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
style="padding: 7px 16px; margin-bottom: 6px"
|
||||
@click="selectAllGroups">
|
||||
{{
|
||||
userDialogGroupAllSelected
|
||||
? t('dialog.group.actions.deselect_all')
|
||||
: t('dialog.group.actions.select_all')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-for="group in userDialogGroupEditGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-full hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<!-- Manual checkbox -->
|
||||
<div
|
||||
style="
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
transform: scale(0.8);
|
||||
transform-origin: left center;
|
||||
"
|
||||
@click.stop>
|
||||
<Checkbox
|
||||
:model-value="userDialogGroupEditSelectedGroupIds.includes(group.id)"
|
||||
@update:modelValue="() => toggleGroupSelection(group.id)" />
|
||||
</div>
|
||||
|
||||
<div style="margin-right: 3px; margin-left: 6px" @click.stop>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
style="display: block; padding: 7px; font-size: 9px; margin-left: 0; rotate: 180deg"
|
||||
@click="moveGroupTop(group.id)">
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
style="display: block; padding: 7px; font-size: 9px; margin-left: 0"
|
||||
@click="moveGroupBottom(group.id)">
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div style="margin-right: 8px" @click.stop>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
style="display: block; padding: 7px; font-size: 9px; margin-left: 0"
|
||||
@click="moveGroupUp(group.id)">
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
style="display: block; padding: 7px; font-size: 9px; margin-left: 0"
|
||||
@click="moveGroupDown(group.id)">
|
||||
<ArrowDown />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<img class="size-full rounded-full object-cover" :src="group.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<span class="block truncate text-xs">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="group.myMember?.visibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span
|
||||
>{{ t('dialog.group.members.visibility') }}
|
||||
{{ group.myMember.visibility }}</span
|
||||
>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
v-if="group.myMember?.visibility"
|
||||
:model-value="group.myMember.visibility"
|
||||
:disabled="group.privacy !== 'default'"
|
||||
@update:modelValue="(value) => setGroupVisibility(group.id, value)">
|
||||
<SelectTrigger size="sm" @click.stop>
|
||||
<SelectValue
|
||||
:placeholder="
|
||||
group.myMember.visibility === 'visible'
|
||||
? t('dialog.group.tags.visible')
|
||||
: group.myMember.visibility === 'friends'
|
||||
? t('dialog.group.tags.friends')
|
||||
: group.myMember.visibility === 'hidden'
|
||||
? t('dialog.group.tags.hidden')
|
||||
: group.myMember.visibility
|
||||
" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="visible">
|
||||
{{ t('dialog.group.actions.visibility_everyone') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="friends">
|
||||
{{ t('dialog.group.actions.visibility_friends') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="hidden">
|
||||
{{ t('dialog.group.actions.visibility_hidden') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<!--//- JSON is missing isSubscribedToAnnouncements, can't be implemented-->
|
||||
<!-- <Button size="sm" variant="outline"
|
||||
@click.stop="
|
||||
setGroupSubscription(group.id, !group.myMember.isSubscribedToAnnouncements)
|
||||
">
|
||||
<span v-if="group.myMember.isSubscribedToAnnouncements"
|
||||
><BellOff style="margin-left: 6px" />
|
||||
{{ t('dialog.group.tags.subscribed') }}</span
|
||||
>
|
||||
<span v-else
|
||||
><Bell style="margin-left: 6px" />
|
||||
{{ t('dialog.group.tags.unsubscribed') }}</span
|
||||
>
|
||||
</Button> -->
|
||||
<TooltipWrapper side="right" :content="t('dialog.user.groups.leave_group_tooltip')">
|
||||
<Button
|
||||
class="rounded-full h-6 w-6"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
v-if="shiftHeld"
|
||||
style="margin-left: 6px"
|
||||
@click.stop="leaveGroup(group.id)">
|
||||
<LogOut />
|
||||
</Button>
|
||||
<Button
|
||||
class="rounded-full h-6 w-6 text-red-600"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
v-else
|
||||
style="margin-left: 6px"
|
||||
@click.stop="leaveGroupPrompt(group.id)">
|
||||
<LogOut />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="userDialog.userGroups.ownGroups.length > 0">
|
||||
<span style="font-weight: bold; font-size: 16px">{{ t('dialog.user.groups.own_groups') }}</span>
|
||||
<span style="font-size: 12px; margin-left: 6px"
|
||||
>{{ userDialog.userGroups.ownGroups.length }}/{{
|
||||
// @ts-ignore
|
||||
cachedConfig?.constants?.GROUPS?.MAX_OWNED
|
||||
}}</span
|
||||
>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; margin-bottom: 16px; min-height: 60px">
|
||||
<div
|
||||
v-for="group in userDialog.userGroups.ownGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<img class="size-full rounded-full object-cover" :src="group.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<span class="block truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="group.memberVisibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span
|
||||
>{{ t('dialog.group.members.visibility') }}
|
||||
{{ group.memberVisibility }}</span
|
||||
>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="userDialog.userGroups.mutualGroups.length > 0">
|
||||
<span style="font-weight: bold; font-size: 16px">{{ t('dialog.user.groups.mutual_groups') }}</span>
|
||||
<span style="font-size: 12px; margin-left: 6px">{{ userDialog.userGroups.mutualGroups.length }}</span>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; margin-bottom: 16px; min-height: 60px">
|
||||
<div
|
||||
v-for="group in userDialog.userGroups.mutualGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<img class="size-full rounded-full object-cover" :src="group.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<span class="block truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="group.memberVisibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span
|
||||
>{{ t('dialog.group.members.visibility') }}
|
||||
{{ group.memberVisibility }}</span
|
||||
>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="userDialog.userGroups.remainingGroups.length > 0">
|
||||
<span style="font-weight: bold; font-size: 16px">{{ t('dialog.user.groups.groups') }}</span>
|
||||
<span style="font-size: 12px; margin-left: 6px">
|
||||
{{ userDialog.userGroups.remainingGroups.length }}
|
||||
<template v-if="currentUser.id === userDialog.id">
|
||||
/
|
||||
<template v-if="isLocalUserVrcPlusSupporter">
|
||||
{{ cachedConfig?.constants?.GROUPS?.MAX_JOINED_PLUS }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ cachedConfig?.constants?.GROUPS?.MAX_JOINED }}
|
||||
</template>
|
||||
</template>
|
||||
</span>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; margin-bottom: 16px; min-height: 60px">
|
||||
<div
|
||||
v-for="group in userDialog.userGroups.remainingGroups"
|
||||
:key="group.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showGroupDialog(group.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<img class="size-full rounded-full object-cover" :src="group.iconUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="group.name"></span>
|
||||
<div class="block truncate text-xs inline-flex! items-center">
|
||||
<TooltipWrapper
|
||||
v-if="group.isRepresenting"
|
||||
side="top"
|
||||
:content="t('dialog.group.members.representing')">
|
||||
<Tag style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="group.memberVisibility !== 'visible'" side="top">
|
||||
<template #content>
|
||||
<span
|
||||
>{{ t('dialog.group.members.visibility') }}
|
||||
{{ group.memberVisibility }}</span
|
||||
>
|
||||
</template>
|
||||
<Eye style="margin-right: 6px" />
|
||||
</TooltipWrapper>
|
||||
<span>({{ group.memberCount }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowDown, ArrowUp, DownloadIcon, Eye, LogOut, RefreshCw, Tag } from 'lucide-vue-next';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useAuthStore, useGroupStore, useUiStore, useUserStore } from '../../../stores';
|
||||
import { compareByMemberCount, compareByName } from '../../../shared/utils';
|
||||
import { groupRequest } from '../../../api';
|
||||
import { useOptionKeySelect } from '../../../composables/useOptionKeySelect';
|
||||
import { userDialogGroupSortingOptions } from '../../../shared/constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { userDialog, currentUser, isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());
|
||||
const {
|
||||
showGroupDialog,
|
||||
applyGroup,
|
||||
saveCurrentUserGroups,
|
||||
updateInGameGroupOrder,
|
||||
leaveGroup,
|
||||
leaveGroupPrompt,
|
||||
setGroupVisibility,
|
||||
handleGroupList
|
||||
} = useGroupStore();
|
||||
const { currentUserGroups, inGameGroupOrder } = storeToRefs(useGroupStore());
|
||||
const { cachedConfig } = storeToRefs(useAuthStore());
|
||||
const { shiftHeld } = storeToRefs(useUiStore());
|
||||
|
||||
const userDialogGroupEditMode = ref(false);
|
||||
const userDialogGroupEditGroups = ref([]);
|
||||
const userDialogGroupAllSelected = ref(false);
|
||||
const userDialogGroupEditSelectedGroupIds = ref([]);
|
||||
|
||||
const { selectedKey: userDialogGroupSortingKey, selectByKey: setUserDialogGroupSortingByKey } = useOptionKeySelect(
|
||||
userDialogGroupSortingOptions,
|
||||
() => userDialog.value.groupSorting,
|
||||
setUserDialogGroupSorting
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param sortOrder
|
||||
*/
|
||||
async function setUserDialogGroupSorting(sortOrder) {
|
||||
const D = userDialog.value;
|
||||
if (D.groupSorting.value === sortOrder.value) {
|
||||
return;
|
||||
}
|
||||
D.groupSorting = sortOrder;
|
||||
await sortCurrentUserGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
async function getUserGroups(userId) {
|
||||
exitEditModeCurrentUserGroups();
|
||||
userDialog.value.isGroupsLoading = true;
|
||||
userDialog.value.userGroups = {
|
||||
groups: [],
|
||||
ownGroups: [],
|
||||
mutualGroups: [],
|
||||
remainingGroups: []
|
||||
};
|
||||
const args = await groupRequest.getGroups({ userId });
|
||||
handleGroupList(args);
|
||||
if (userId !== userDialog.value.id) {
|
||||
userDialog.value.isGroupsLoading = false;
|
||||
return;
|
||||
}
|
||||
if (userId === currentUser.value.id) {
|
||||
// update current user groups
|
||||
currentUserGroups.value.clear();
|
||||
args.json.forEach((group) => {
|
||||
const ref = applyGroup(group);
|
||||
if (!currentUserGroups.value.has(group.id)) {
|
||||
currentUserGroups.value.set(group.id, ref);
|
||||
}
|
||||
});
|
||||
|
||||
saveCurrentUserGroups();
|
||||
}
|
||||
userDialog.value.userGroups.groups = args.json;
|
||||
for (let i = 0; i < args.json.length; ++i) {
|
||||
const group = args.json[i];
|
||||
if (!group?.id) {
|
||||
console.error('getUserGroups, group ID is missing', group);
|
||||
continue;
|
||||
}
|
||||
if (group.ownerId === userId) {
|
||||
userDialog.value.userGroups.ownGroups.unshift(group);
|
||||
}
|
||||
if (userId === currentUser.value.id) {
|
||||
// skip mutual groups for current user
|
||||
if (group.ownerId !== userId) {
|
||||
userDialog.value.userGroups.remainingGroups.unshift(group);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (group.mutualGroup) {
|
||||
userDialog.value.userGroups.mutualGroups.unshift(group);
|
||||
}
|
||||
if (!group.mutualGroup && group.ownerId !== userId) {
|
||||
userDialog.value.userGroups.remainingGroups.unshift(group);
|
||||
}
|
||||
}
|
||||
if (userId === currentUser.value.id) {
|
||||
userDialog.value.groupSorting = userDialogGroupSortingOptions.inGame;
|
||||
} else if (userDialog.value.groupSorting.value === userDialogGroupSortingOptions.inGame.value) {
|
||||
userDialog.value.groupSorting = userDialogGroupSortingOptions.alphabetical;
|
||||
}
|
||||
await sortCurrentUserGroups();
|
||||
userDialog.value.isGroupsLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param a
|
||||
* @param b
|
||||
*/
|
||||
function sortGroupsByInGame(a, b) {
|
||||
const aIndex = inGameGroupOrder.value.indexOf(a?.id);
|
||||
const bIndex = inGameGroupOrder.value.indexOf(b?.id);
|
||||
if (aIndex === -1 && bIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
if (aIndex === -1) {
|
||||
return 1;
|
||||
}
|
||||
if (bIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function sortCurrentUserGroups() {
|
||||
const D = userDialog.value;
|
||||
let sortMethod = () => 0;
|
||||
|
||||
switch (D.groupSorting.value) {
|
||||
case 'alphabetical':
|
||||
sortMethod = compareByName;
|
||||
break;
|
||||
case 'members':
|
||||
sortMethod = compareByMemberCount;
|
||||
break;
|
||||
case 'inGame':
|
||||
sortMethod = sortGroupsByInGame;
|
||||
await updateInGameGroupOrder();
|
||||
break;
|
||||
}
|
||||
|
||||
userDialog.value.userGroups.ownGroups.sort(sortMethod);
|
||||
userDialog.value.userGroups.mutualGroups.sort(sortMethod);
|
||||
userDialog.value.userGroups.remainingGroups.sort(sortMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function exitEditModeCurrentUserGroups() {
|
||||
userDialogGroupEditMode.value = false;
|
||||
userDialogGroupEditGroups.value = [];
|
||||
userDialogGroupEditSelectedGroupIds.value = [];
|
||||
userDialogGroupAllSelected.value = false;
|
||||
await sortCurrentUserGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function editModeCurrentUserGroups() {
|
||||
await updateInGameGroupOrder();
|
||||
userDialogGroupEditGroups.value = Array.from(currentUserGroups.value.values());
|
||||
userDialogGroupEditGroups.value.sort(sortGroupsByInGame);
|
||||
userDialogGroupEditMode.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function saveInGameGroupOrder() {
|
||||
userDialogGroupEditGroups.value.sort(sortGroupsByInGame);
|
||||
try {
|
||||
await AppApi.SetVRChatRegistryKey(
|
||||
`VRC_GROUP_ORDER_${currentUser.value.id}`,
|
||||
JSON.stringify(inGameGroupOrder.value),
|
||||
3
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Failed to save in-game group order');
|
||||
}
|
||||
}
|
||||
|
||||
// Select all groups currently in the editable list by collecting their IDs
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function selectAllGroups() {
|
||||
const allSelected = userDialogGroupEditSelectedGroupIds.value.length === userDialogGroupEditGroups.value.length;
|
||||
|
||||
// First update selection state
|
||||
userDialogGroupEditSelectedGroupIds.value = allSelected ? [] : userDialogGroupEditGroups.value.map((g) => g.id);
|
||||
userDialogGroupAllSelected.value = !allSelected;
|
||||
|
||||
// Toggle editMode off and back on to force checkbox UI update
|
||||
userDialogGroupEditMode.value = false;
|
||||
nextTick(() => {
|
||||
userDialogGroupEditMode.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
const bulkGroupActionValue = ref('');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
function handleBulkGroupAction(value) {
|
||||
bulkGroupActionValue.value = value;
|
||||
|
||||
if (value === 'leave') {
|
||||
bulkLeaveGroups();
|
||||
} else if (typeof value === 'string' && value.startsWith('visibility:')) {
|
||||
const newVisibility = value.slice('visibility:'.length);
|
||||
bulkSetVisibility(newVisibility);
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
bulkGroupActionValue.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the given visibility to all selected groups
|
||||
/**
|
||||
*
|
||||
* @param newVisibility
|
||||
*/
|
||||
async function bulkSetVisibility(newVisibility) {
|
||||
for (const groupId of userDialogGroupEditSelectedGroupIds.value) {
|
||||
setGroupVisibility(groupId, newVisibility);
|
||||
}
|
||||
}
|
||||
|
||||
// Leave (remove user from) all selected groups
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function bulkLeaveGroups() {
|
||||
for (const groupId of userDialogGroupEditSelectedGroupIds.value) {
|
||||
leaveGroup(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle individual group selection for bulk actions
|
||||
/**
|
||||
*
|
||||
* @param groupId
|
||||
*/
|
||||
function toggleGroupSelection(groupId) {
|
||||
const index = userDialogGroupEditSelectedGroupIds.value.indexOf(groupId);
|
||||
if (index === -1) {
|
||||
userDialogGroupEditSelectedGroupIds.value.push(groupId);
|
||||
} else {
|
||||
userDialogGroupEditSelectedGroupIds.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupId
|
||||
*/
|
||||
function moveGroupUp(groupId) {
|
||||
const index = inGameGroupOrder.value.indexOf(groupId);
|
||||
if (index > 0) {
|
||||
inGameGroupOrder.value.splice(index, 1);
|
||||
inGameGroupOrder.value.splice(index - 1, 0, groupId);
|
||||
saveInGameGroupOrder();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupId
|
||||
*/
|
||||
function moveGroupDown(groupId) {
|
||||
const index = inGameGroupOrder.value.indexOf(groupId);
|
||||
if (index < inGameGroupOrder.value.length - 1) {
|
||||
inGameGroupOrder.value.splice(index, 1);
|
||||
inGameGroupOrder.value.splice(index + 1, 0, groupId);
|
||||
saveInGameGroupOrder();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupId
|
||||
*/
|
||||
function moveGroupTop(groupId) {
|
||||
const index = inGameGroupOrder.value.indexOf(groupId);
|
||||
if (index > 0) {
|
||||
inGameGroupOrder.value.splice(index, 1);
|
||||
inGameGroupOrder.value.unshift(groupId);
|
||||
saveInGameGroupOrder();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupId
|
||||
*/
|
||||
function moveGroupBottom(groupId) {
|
||||
const index = inGameGroupOrder.value.indexOf(groupId);
|
||||
if (index < inGameGroupOrder.value.length - 1) {
|
||||
inGameGroupOrder.value.splice(index, 1);
|
||||
inGameGroupOrder.value.push(groupId);
|
||||
saveInGameGroupOrder();
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ getUserGroups });
|
||||
</script>
|
||||
154
src/components/dialogs/UserDialog/UserDialogMutualFriendsTab.vue
Normal file
154
src/components/dialogs/UserDialog/UserDialogMutualFriendsTab.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="userDialog.isMutualFriendsLoading"
|
||||
@click="getUserMutualFriends(userDialog.id)">
|
||||
<Spinner v-if="userDialog.isMutualFriendsLoading" />
|
||||
<RefreshCw v-else />
|
||||
</Button>
|
||||
<span style="margin-left: 6px">{{
|
||||
t('dialog.user.groups.total_count', { count: userDialog.mutualFriends.length })
|
||||
}}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<span style="margin-right: 6px">{{ t('dialog.user.groups.sort_by') }}</span>
|
||||
<Select
|
||||
:model-value="userDialogMutualFriendSortingKey"
|
||||
:disabled="userDialog.isMutualFriendsLoading"
|
||||
@update:modelValue="setUserDialogMutualFriendSortingByKey">
|
||||
<SelectTrigger size="sm" @click.stop>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="(item, key) in userDialogMutualFriendSortingOptions"
|
||||
:key="String(key)"
|
||||
:value="String(key)">
|
||||
{{ t(item.name) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="flex flex-wrap items-start" style="margin-top: 8px; overflow: auto; max-height: 250px; min-width: 130px">
|
||||
<li
|
||||
v-for="user in userDialog.mutualFriends"
|
||||
: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">
|
||||
<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>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-vue-next';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import {
|
||||
compareByDisplayName,
|
||||
compareByFriendOrder,
|
||||
compareByLastActiveRef,
|
||||
userImage
|
||||
} from '../../../shared/utils';
|
||||
import { database } from '../../../service/database';
|
||||
import { processBulk } from '../../../service/request';
|
||||
import { useOptionKeySelect } from '../../../composables/useOptionKeySelect';
|
||||
import { useUserStore } from '../../../stores';
|
||||
import { userDialogMutualFriendSortingOptions } from '../../../shared/constants';
|
||||
import { userRequest } from '../../../api';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userDialog, currentUser } = storeToRefs(userStore);
|
||||
const { cachedUsers, showUserDialog } = userStore;
|
||||
|
||||
const { selectedKey: userDialogMutualFriendSortingKey, selectByKey: setUserDialogMutualFriendSortingByKey } =
|
||||
useOptionKeySelect(
|
||||
userDialogMutualFriendSortingOptions,
|
||||
() => userDialog.value.mutualFriendSorting,
|
||||
setUserDialogMutualFriendSorting
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param sortOrder
|
||||
*/
|
||||
async function setUserDialogMutualFriendSorting(sortOrder) {
|
||||
const D = userDialog.value;
|
||||
D.mutualFriendSorting = sortOrder;
|
||||
switch (sortOrder.value) {
|
||||
case 'alphabetical':
|
||||
D.mutualFriends.sort(compareByDisplayName);
|
||||
break;
|
||||
case 'lastActive':
|
||||
D.mutualFriends.sort(compareByLastActiveRef);
|
||||
break;
|
||||
case 'friendOrder':
|
||||
D.mutualFriends.sort(compareByFriendOrder);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
async function getUserMutualFriends(userId) {
|
||||
userDialog.value.mutualFriends = [];
|
||||
if (currentUser.value.hasSharedConnectionsOptOut) {
|
||||
return;
|
||||
}
|
||||
userDialog.value.isMutualFriendsLoading = true;
|
||||
const params = {
|
||||
userId,
|
||||
n: 100,
|
||||
offset: 0
|
||||
};
|
||||
processBulk({
|
||||
fn: userRequest.getMutualFriends,
|
||||
N: -1,
|
||||
params,
|
||||
handle: (args) => {
|
||||
for (const json of args.json) {
|
||||
if (userDialog.value.mutualFriends.some((u) => u.id === json.id)) {
|
||||
continue;
|
||||
}
|
||||
const ref = cachedUsers.get(json.id);
|
||||
if (typeof ref !== 'undefined') {
|
||||
userDialog.value.mutualFriends.push(ref);
|
||||
} else {
|
||||
userDialog.value.mutualFriends.push(json);
|
||||
}
|
||||
}
|
||||
setUserDialogMutualFriendSorting(userDialog.value.mutualFriendSorting);
|
||||
},
|
||||
done: (success) => {
|
||||
userDialog.value.isMutualFriendsLoading = false;
|
||||
if (success) {
|
||||
const mutualIds = userDialog.value.mutualFriends.map((u) => u.id);
|
||||
database.updateMutualsForFriend(userId, mutualIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({ getUserMutualFriends });
|
||||
</script>
|
||||
214
src/components/dialogs/UserDialog/UserDialogWorldsTab.vue
Normal file
214
src/components/dialogs/UserDialog/UserDialogWorldsTab.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="userDialog.isWorldsLoading"
|
||||
@click="refreshUserDialogWorlds()">
|
||||
<Spinner v-if="userDialog.isWorldsLoading" />
|
||||
<RefreshCw v-else />
|
||||
</Button>
|
||||
<span style="margin-left: 6px">{{
|
||||
t('dialog.user.worlds.total_count', { count: userDialog.worlds.length })
|
||||
}}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<span class="mr-1">{{ t('dialog.user.worlds.sort_by') }}</span>
|
||||
<Select
|
||||
:model-value="userDialogWorldSortingKey"
|
||||
:disabled="userDialog.isWorldsLoading"
|
||||
@update:modelValue="setUserDialogWorldSortingByKey">
|
||||
<SelectTrigger size="sm" @click.stop>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="(item, key) in userDialogWorldSortingOptions"
|
||||
:key="String(key)"
|
||||
:value="String(key)">
|
||||
{{ t(item.name) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="ml-2 mr-1">{{ t('dialog.user.worlds.order_by') }}</span>
|
||||
<Select
|
||||
:model-value="userDialogWorldOrderKey"
|
||||
:disabled="userDialog.isWorldsLoading"
|
||||
@update:modelValue="setUserDialogWorldOrderByKey">
|
||||
<SelectTrigger size="sm" @click.stop>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="(item, key) in userDialogWorldOrderOptions"
|
||||
:key="String(key)"
|
||||
:value="String(key)">
|
||||
{{ t(item.name) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-start" style="margin-top: 8px; min-height: 60px">
|
||||
<template v-if="userDialog.worlds.length">
|
||||
<div
|
||||
v-for="world in userDialog.worlds"
|
||||
:key="world.id"
|
||||
class="box-border flex items-center p-1.5 text-[13px] cursor-pointer w-[167px] hover:rounded-[25px_5px_5px_25px]"
|
||||
@click="showWorldDialog(world.id)">
|
||||
<div class="relative inline-block flex-none size-9 mr-2.5">
|
||||
<img class="size-full rounded-full object-cover" :src="world.thumbnailImageUrl" loading="lazy" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<span class="block truncate font-medium leading-[18px]" v-text="world.name"></span>
|
||||
<span v-if="world.occupants" class="block truncate text-xs">({{ world.occupants }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="!userDialog.isWorldsLoading"
|
||||
style="display: flex; justify-content: center; align-items: center; min-height: 120px; width: 100%">
|
||||
<DataTableEmpty type="nodata" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||
import { RefreshCw } from 'lucide-vue-next';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useUserStore, useWorldStore } from '../../../stores';
|
||||
import { userDialogWorldOrderOptions, userDialogWorldSortingOptions } from '../../../shared/constants/';
|
||||
import { useOptionKeySelect } from '../../../composables/useOptionKeySelect';
|
||||
import { worldRequest } from '../../../api';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userDialog, currentUser } = storeToRefs(userStore);
|
||||
const { cachedWorlds, showWorldDialog } = useWorldStore();
|
||||
|
||||
const userDialogWorldsRequestId = ref(0);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
*/
|
||||
function setUserDialogWorlds(userId) {
|
||||
const worlds = [];
|
||||
for (const ref of cachedWorlds.values()) {
|
||||
if (ref.authorId === userId) {
|
||||
worlds.push(ref);
|
||||
}
|
||||
}
|
||||
userDialog.value.worlds = worlds;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function refreshUserDialogWorlds() {
|
||||
const D = userDialog.value;
|
||||
if (D.isWorldsLoading) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++userDialogWorldsRequestId.value;
|
||||
D.isWorldsLoading = true;
|
||||
const params = {
|
||||
n: 50,
|
||||
offset: 0,
|
||||
sort: userDialog.value.worldSorting.value,
|
||||
order: userDialog.value.worldOrder.value,
|
||||
// user: 'friends',
|
||||
userId: D.id,
|
||||
releaseStatus: 'public'
|
||||
};
|
||||
if (params.userId === currentUser.value.id) {
|
||||
params.user = 'me';
|
||||
params.releaseStatus = 'all';
|
||||
}
|
||||
const worlds = [];
|
||||
const worldIds = new Set();
|
||||
(async () => {
|
||||
try {
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const args = await worldRequest.getCachedWorlds({
|
||||
...params,
|
||||
offset
|
||||
});
|
||||
if (requestId !== userDialogWorldsRequestId.value || D.id !== params.userId) {
|
||||
return;
|
||||
}
|
||||
for (const world of args.json) {
|
||||
if (!worldIds.has(world.id)) {
|
||||
worldIds.add(world.id);
|
||||
worlds.push(world);
|
||||
}
|
||||
}
|
||||
if (args.json.length < params.n) {
|
||||
break;
|
||||
}
|
||||
offset += params.n;
|
||||
}
|
||||
if (requestId === userDialogWorldsRequestId.value && D.id === params.userId) {
|
||||
userDialog.value.worlds = worlds;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === userDialogWorldsRequestId.value) {
|
||||
D.isWorldsLoading = false;
|
||||
}
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error('refreshUserDialogWorlds failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param sortOrder
|
||||
*/
|
||||
async function setUserDialogWorldSorting(sortOrder) {
|
||||
const D = userDialog.value;
|
||||
if (D.worldSorting.value === sortOrder.value) {
|
||||
return;
|
||||
}
|
||||
D.worldSorting = sortOrder;
|
||||
refreshUserDialogWorlds();
|
||||
}
|
||||
|
||||
const { selectedKey: userDialogWorldSortingKey, selectByKey: setUserDialogWorldSortingByKey } = useOptionKeySelect(
|
||||
userDialogWorldSortingOptions,
|
||||
() => userDialog.value.worldSorting,
|
||||
setUserDialogWorldSorting
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param order
|
||||
*/
|
||||
async function setUserDialogWorldOrder(order) {
|
||||
const D = userDialog.value;
|
||||
if (D.worldOrder.value === order.value) {
|
||||
return;
|
||||
}
|
||||
D.worldOrder = order;
|
||||
refreshUserDialogWorlds();
|
||||
}
|
||||
|
||||
const { selectedKey: userDialogWorldOrderKey, selectByKey: setUserDialogWorldOrderByKey } = useOptionKeySelect(
|
||||
userDialogWorldOrderOptions,
|
||||
() => userDialog.value.worldOrder,
|
||||
setUserDialogWorldOrder
|
||||
);
|
||||
|
||||
defineExpose({ setUserDialogWorlds, refreshUserDialogWorlds });
|
||||
</script>
|
||||
@@ -0,0 +1,262 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
// ─── Mocks (must be before any imports that use them) ────────────────
|
||||
|
||||
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 }
|
||||
}));
|
||||
|
||||
import UserDialogAvatarsTab from '../UserDialogAvatarsTab.vue';
|
||||
import { useUserStore } from '../../../../stores';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_AVATARS = [
|
||||
{
|
||||
id: 'avtr_1',
|
||||
name: 'Alpha',
|
||||
thumbnailImageUrl: 'https://img/1.png',
|
||||
releaseStatus: 'public',
|
||||
authorId: 'usr_me'
|
||||
},
|
||||
{
|
||||
id: 'avtr_2',
|
||||
name: 'Beta',
|
||||
thumbnailImageUrl: 'https://img/2.png',
|
||||
releaseStatus: 'private',
|
||||
authorId: 'usr_me'
|
||||
},
|
||||
{
|
||||
id: 'avtr_3',
|
||||
name: 'Gamma',
|
||||
thumbnailImageUrl: 'https://img/3.png',
|
||||
releaseStatus: 'public',
|
||||
authorId: 'usr_me'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param overrides
|
||||
*/
|
||||
function mountComponent(overrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false
|
||||
});
|
||||
|
||||
const userStore = useUserStore(pinia);
|
||||
userStore.userDialog = {
|
||||
id: 'usr_me',
|
||||
ref: { id: 'usr_me' },
|
||||
avatars: [...MOCK_AVATARS],
|
||||
avatarSorting: 'name',
|
||||
avatarReleaseStatus: 'all',
|
||||
isAvatarsLoading: false,
|
||||
isWorldsLoading: false,
|
||||
...overrides
|
||||
};
|
||||
userStore.currentUser = {
|
||||
id: 'usr_me',
|
||||
...overrides.currentUser
|
||||
};
|
||||
|
||||
return mount(UserDialogAvatarsTab, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
RefreshCw: { template: '<svg class="refresh-icon" />' },
|
||||
DeprecationAlert: {
|
||||
template: '<div class="deprecation-stub" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('UserDialogAvatarsTab.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
test('renders avatar count', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('3');
|
||||
});
|
||||
|
||||
test('renders all avatars when releaseStatus is "all"', () => {
|
||||
const wrapper = mountComponent();
|
||||
const items = wrapper.findAll('.cursor-pointer');
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('renders avatar names', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('Alpha');
|
||||
expect(wrapper.text()).toContain('Beta');
|
||||
expect(wrapper.text()).toContain('Gamma');
|
||||
});
|
||||
|
||||
test('renders avatar thumbnails', () => {
|
||||
const wrapper = mountComponent();
|
||||
const images = wrapper.findAll('img');
|
||||
expect(images).toHaveLength(3);
|
||||
expect(images[0].attributes('src')).toBe('https://img/1.png');
|
||||
});
|
||||
|
||||
test('shows deprecation alert for current user', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.find('.deprecation-stub').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('hides deprecation alert for other users', () => {
|
||||
const wrapper = mountComponent({
|
||||
id: 'usr_other',
|
||||
ref: { id: 'usr_other' }
|
||||
});
|
||||
expect(wrapper.find('.deprecation-stub').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('shows empty state when no avatars and not loading', () => {
|
||||
const wrapper = mountComponent({ avatars: [] });
|
||||
expect(wrapper.text()).toContain('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering by releaseStatus', () => {
|
||||
test('shows only public avatars when releaseStatus is "public"', () => {
|
||||
const wrapper = mountComponent({ avatarReleaseStatus: 'public' });
|
||||
expect(wrapper.text()).toContain('Alpha');
|
||||
expect(wrapper.text()).toContain('Gamma');
|
||||
expect(wrapper.text()).not.toContain('Beta');
|
||||
});
|
||||
|
||||
test('shows only private avatars when releaseStatus is "private"', () => {
|
||||
const wrapper = mountComponent({ avatarReleaseStatus: 'private' });
|
||||
expect(wrapper.text()).toContain('Beta');
|
||||
expect(wrapper.text()).not.toContain('Alpha');
|
||||
expect(wrapper.text()).not.toContain('Gamma');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
test('renders search input for current user', () => {
|
||||
const wrapper = mountComponent();
|
||||
const input = wrapper.find('input');
|
||||
expect(input.exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not render search input for other users', () => {
|
||||
const wrapper = mountComponent({
|
||||
id: 'usr_other',
|
||||
ref: { id: 'usr_other' }
|
||||
});
|
||||
const input = wrapper.find('input');
|
||||
expect(input.exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('filters avatars by search query', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const input = wrapper.find('input');
|
||||
await input.setValue('alpha');
|
||||
expect(wrapper.text()).toContain('Alpha');
|
||||
expect(wrapper.text()).not.toContain('Beta');
|
||||
expect(wrapper.text()).not.toContain('Gamma');
|
||||
});
|
||||
|
||||
test('search is case-insensitive', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const input = wrapper.find('input');
|
||||
await input.setValue('BETA');
|
||||
expect(wrapper.text()).toContain('Beta');
|
||||
});
|
||||
|
||||
test('shows all avatars when search query is cleared', async () => {
|
||||
const wrapper = mountComponent();
|
||||
const input = wrapper.find('input');
|
||||
await input.setValue('alpha');
|
||||
await input.setValue('');
|
||||
expect(wrapper.text()).toContain('Alpha');
|
||||
expect(wrapper.text()).toContain('Beta');
|
||||
expect(wrapper.text()).toContain('Gamma');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
test('disables refresh button when loading', () => {
|
||||
const wrapper = mountComponent({ isAvatarsLoading: true });
|
||||
const button = wrapper.find('button');
|
||||
expect(button.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
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 UserDialogMutualFriendsTab from '../UserDialogMutualFriendsTab.vue';
|
||||
import { useUserStore } from '../../../../stores';
|
||||
import { userDialogMutualFriendSortingOptions } from '../../../../shared/constants';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_MUTUAL_FRIENDS = [
|
||||
{
|
||||
id: 'usr_1',
|
||||
displayName: 'Charlie',
|
||||
$userColour: '#ff0000',
|
||||
currentAvatarThumbnailImageUrl: 'https://img/charlie.png'
|
||||
},
|
||||
{
|
||||
id: 'usr_2',
|
||||
displayName: 'Alice',
|
||||
$userColour: '#00ff00',
|
||||
currentAvatarThumbnailImageUrl: 'https://img/alice.png'
|
||||
},
|
||||
{
|
||||
id: 'usr_3',
|
||||
displayName: 'Bob',
|
||||
$userColour: '#0000ff',
|
||||
currentAvatarThumbnailImageUrl: 'https://img/bob.png'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param overrides
|
||||
*/
|
||||
function mountComponent(overrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false
|
||||
});
|
||||
|
||||
const userStore = useUserStore(pinia);
|
||||
userStore.userDialog = {
|
||||
id: 'usr_target',
|
||||
ref: { id: 'usr_target' },
|
||||
mutualFriends: [...MOCK_MUTUAL_FRIENDS],
|
||||
mutualFriendSorting: userDialogMutualFriendSortingOptions.alphabetical,
|
||||
isMutualFriendsLoading: false,
|
||||
...overrides
|
||||
};
|
||||
userStore.currentUser = {
|
||||
id: 'usr_me',
|
||||
hasSharedConnectionsOptOut: false,
|
||||
...overrides.currentUser
|
||||
};
|
||||
|
||||
return mount(UserDialogMutualFriendsTab, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
RefreshCw: { template: '<svg class="refresh-icon" />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('UserDialogMutualFriendsTab.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
test('renders mutual friend count', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('3');
|
||||
});
|
||||
|
||||
test('renders all mutual friends', () => {
|
||||
const wrapper = mountComponent();
|
||||
const items = wrapper.findAll('li');
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('renders friend display names', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('Charlie');
|
||||
expect(wrapper.text()).toContain('Alice');
|
||||
expect(wrapper.text()).toContain('Bob');
|
||||
});
|
||||
|
||||
test('renders friend avatar images', () => {
|
||||
const wrapper = mountComponent();
|
||||
const images = wrapper.findAll('img');
|
||||
expect(images).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('applies user colour to display name', () => {
|
||||
const wrapper = mountComponent();
|
||||
const nameSpan = wrapper.find('span[style*="color"]');
|
||||
expect(nameSpan.exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('renders empty list when no mutual friends', () => {
|
||||
const wrapper = mountComponent({ mutualFriends: [] });
|
||||
const items = wrapper.findAll('li');
|
||||
expect(items).toHaveLength(0);
|
||||
expect(wrapper.text()).toContain('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
test('disables refresh button when loading', () => {
|
||||
const wrapper = mountComponent({ isMutualFriendsLoading: true });
|
||||
const button = wrapper.find('button');
|
||||
expect(button.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
test('refresh button is enabled when not loading', () => {
|
||||
const wrapper = mountComponent({ isMutualFriendsLoading: false });
|
||||
const button = wrapper.find('button');
|
||||
expect(button.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('click interactions', () => {
|
||||
test('calls showUserDialog when a friend is clicked', async () => {
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
const userStore = useUserStore(pinia);
|
||||
userStore.userDialog = {
|
||||
id: 'usr_target',
|
||||
ref: { id: 'usr_target' },
|
||||
mutualFriends: [...MOCK_MUTUAL_FRIENDS],
|
||||
mutualFriendSorting:
|
||||
userDialogMutualFriendSortingOptions.alphabetical,
|
||||
isMutualFriendsLoading: false
|
||||
};
|
||||
userStore.currentUser = { id: 'usr_me' };
|
||||
const showUserDialogSpy = vi
|
||||
.spyOn(userStore, 'showUserDialog')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const wrapper = mount(UserDialogMutualFriendsTab, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
RefreshCw: { template: '<svg class="refresh-icon" />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const firstItem = wrapper.findAll('li')[0];
|
||||
await firstItem.trigger('click');
|
||||
expect(showUserDialogSpy).toHaveBeenCalledWith('usr_1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
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 UserDialogWorldsTab from '../UserDialogWorldsTab.vue';
|
||||
import { useUserStore } from '../../../../stores';
|
||||
import {
|
||||
userDialogWorldSortingOptions,
|
||||
userDialogWorldOrderOptions
|
||||
} from '../../../../shared/constants';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_WORLDS = [
|
||||
{
|
||||
id: 'wrld_1',
|
||||
name: 'Sunset Valley',
|
||||
thumbnailImageUrl: 'https://img/world1.png',
|
||||
occupants: 12,
|
||||
authorId: 'usr_me'
|
||||
},
|
||||
{
|
||||
id: 'wrld_2',
|
||||
name: 'Midnight Club',
|
||||
thumbnailImageUrl: 'https://img/world2.png',
|
||||
occupants: 5,
|
||||
authorId: 'usr_me'
|
||||
},
|
||||
{
|
||||
id: 'wrld_3',
|
||||
name: 'Cozy Cottage',
|
||||
thumbnailImageUrl: 'https://img/world3.png',
|
||||
occupants: 0,
|
||||
authorId: 'usr_me'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param overrides
|
||||
*/
|
||||
function mountComponent(overrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false
|
||||
});
|
||||
|
||||
const userStore = useUserStore(pinia);
|
||||
userStore.userDialog = {
|
||||
id: 'usr_me',
|
||||
ref: { id: 'usr_me' },
|
||||
worlds: [...MOCK_WORLDS],
|
||||
worldSorting: userDialogWorldSortingOptions.name,
|
||||
worldOrder: userDialogWorldOrderOptions.descending,
|
||||
isWorldsLoading: false,
|
||||
...overrides
|
||||
};
|
||||
userStore.currentUser = {
|
||||
id: 'usr_me',
|
||||
...overrides.currentUser
|
||||
};
|
||||
|
||||
return mount(UserDialogWorldsTab, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
RefreshCw: { template: '<svg class="refresh-icon" />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('UserDialogWorldsTab.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
test('renders world count', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('3');
|
||||
});
|
||||
|
||||
test('renders all worlds', () => {
|
||||
const wrapper = mountComponent();
|
||||
const items = wrapper.findAll('.cursor-pointer');
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('renders world names', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('Sunset Valley');
|
||||
expect(wrapper.text()).toContain('Midnight Club');
|
||||
expect(wrapper.text()).toContain('Cozy Cottage');
|
||||
});
|
||||
|
||||
test('renders world thumbnail images', () => {
|
||||
const wrapper = mountComponent();
|
||||
const images = wrapper.findAll('img');
|
||||
expect(images).toHaveLength(3);
|
||||
expect(images[0].attributes('src')).toBe('https://img/world1.png');
|
||||
});
|
||||
|
||||
test('renders occupant count for worlds with occupants', () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.text()).toContain('12');
|
||||
expect(wrapper.text()).toContain('5');
|
||||
});
|
||||
|
||||
test('does not render occupant count for worlds with zero occupants', () => {
|
||||
const wrapper = mountComponent({
|
||||
worlds: [
|
||||
{
|
||||
id: 'wrld_3',
|
||||
name: 'Empty',
|
||||
thumbnailImageUrl: '',
|
||||
occupants: 0
|
||||
}
|
||||
]
|
||||
});
|
||||
// The (0) should NOT be rendered because v-if="world.occupants" is falsy for 0
|
||||
const items = wrapper.findAll('.cursor-pointer');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(wrapper.text()).not.toContain('(0)');
|
||||
});
|
||||
|
||||
test('renders empty state when no worlds and not loading', () => {
|
||||
const wrapper = mountComponent({ worlds: [] });
|
||||
expect(wrapper.text()).toContain('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
test('disables refresh button when loading', () => {
|
||||
const wrapper = mountComponent({ isWorldsLoading: true });
|
||||
const button = wrapper.find('button');
|
||||
expect(button.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
test('refresh button is enabled when not loading', () => {
|
||||
const wrapper = mountComponent({ isWorldsLoading: false });
|
||||
const button = wrapper.find('button');
|
||||
expect(button.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('click interactions', () => {
|
||||
test('calls showWorldDialog when a world is clicked', async () => {
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
const userStore = useUserStore(pinia);
|
||||
const { useWorldStore } = await import('../../../../stores');
|
||||
const worldStore = useWorldStore(pinia);
|
||||
const showWorldDialogSpy = vi
|
||||
.spyOn(worldStore, 'showWorldDialog')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
userStore.userDialog = {
|
||||
id: 'usr_me',
|
||||
ref: { id: 'usr_me' },
|
||||
worlds: [...MOCK_WORLDS],
|
||||
worldSorting: userDialogWorldSortingOptions.name,
|
||||
worldOrder: userDialogWorldOrderOptions.descending,
|
||||
isWorldsLoading: false
|
||||
};
|
||||
userStore.currentUser = { id: 'usr_me' };
|
||||
|
||||
const wrapper = mount(UserDialogWorldsTab, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
RefreshCw: { template: '<svg class="refresh-icon" />' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const firstItem = wrapper.findAll('.cursor-pointer')[0];
|
||||
await firstItem.trigger('click');
|
||||
expect(showWorldDialogSpy).toHaveBeenCalledWith('wrld_1');
|
||||
});
|
||||
});
|
||||
});
|
||||
196
src/composables/__tests__/useOptionKeySelect.test.js
Normal file
196
src/composables/__tests__/useOptionKeySelect.test.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useOptionKeySelect } from '../useOptionKeySelect';
|
||||
|
||||
const OPTIONS = {
|
||||
alphabetical: { value: 'alphabetical', name: 'sort.alphabetical' },
|
||||
members: { value: 'members', name: 'sort.members' },
|
||||
recent: { value: 'recent', name: 'sort.recent' }
|
||||
};
|
||||
|
||||
describe('useOptionKeySelect', () => {
|
||||
describe('selectedKey', () => {
|
||||
test('returns the key when current value is an exact reference match', () => {
|
||||
const current = ref(OPTIONS.members);
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('members');
|
||||
});
|
||||
|
||||
test('returns the key when matching by value property', () => {
|
||||
const current = ref({
|
||||
value: 'alphabetical',
|
||||
name: 'sort.alphabetical'
|
||||
});
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('alphabetical');
|
||||
});
|
||||
|
||||
test('returns the key when matching by name property only', () => {
|
||||
const current = ref({ value: 'different', name: 'sort.recent' });
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('recent');
|
||||
});
|
||||
|
||||
test('returns empty string when no match is found', () => {
|
||||
const current = ref({ value: 'unknown', name: 'sort.unknown' });
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string when current value is null', () => {
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => null,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string when current value is undefined', () => {
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => undefined,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('');
|
||||
});
|
||||
|
||||
test('is reactive to changes in the getter', () => {
|
||||
const current = ref(OPTIONS.alphabetical);
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('alphabetical');
|
||||
|
||||
current.value = OPTIONS.recent;
|
||||
expect(selectedKey.value).toBe('recent');
|
||||
});
|
||||
|
||||
test('returns the first matching key when multiple options could match', () => {
|
||||
const dupeOptions = {
|
||||
first: { value: 'shared', name: 'sort.shared' },
|
||||
second: { value: 'shared', name: 'sort.shared' }
|
||||
};
|
||||
const current = ref({ value: 'shared', name: 'sort.shared' });
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
dupeOptions,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('first');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectByKey', () => {
|
||||
test('calls onSelect with the correct option when key exists', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { selectByKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => null,
|
||||
onSelect
|
||||
);
|
||||
|
||||
selectByKey('members');
|
||||
expect(onSelect).toHaveBeenCalledWith(OPTIONS.members);
|
||||
});
|
||||
|
||||
test('does not call onSelect when key does not exist', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { selectByKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => null,
|
||||
onSelect
|
||||
);
|
||||
|
||||
selectByKey('nonexistent');
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not call onSelect when key is empty string', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { selectByKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => null,
|
||||
onSelect
|
||||
);
|
||||
|
||||
selectByKey('');
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('passes the full option object, not just the value', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { selectByKey } = useOptionKeySelect(
|
||||
OPTIONS,
|
||||
() => null,
|
||||
onSelect
|
||||
);
|
||||
|
||||
selectByKey('recent');
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
value: 'recent',
|
||||
name: 'sort.recent'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('works with empty options map', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { selectedKey, selectByKey } = useOptionKeySelect(
|
||||
{},
|
||||
() => null,
|
||||
onSelect
|
||||
);
|
||||
expect(selectedKey.value).toBe('');
|
||||
|
||||
selectByKey('anything');
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('works with numeric keys in options map', () => {
|
||||
const numericOptions = {
|
||||
0: { value: 'zero', name: 'sort.zero' },
|
||||
1: { value: 'one', name: 'sort.one' }
|
||||
};
|
||||
const current = ref(numericOptions[1]);
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
numericOptions,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('1');
|
||||
});
|
||||
|
||||
test('handles option with missing value property gracefully', () => {
|
||||
const partialOptions = {
|
||||
noValue: { name: 'sort.noValue' }
|
||||
};
|
||||
const current = ref({ name: 'sort.noValue' });
|
||||
const { selectedKey } = useOptionKeySelect(
|
||||
partialOptions,
|
||||
() => current.value,
|
||||
vi.fn()
|
||||
);
|
||||
expect(selectedKey.value).toBe('noValue');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/composables/useOptionKeySelect.js
Normal file
43
src/composables/useOptionKeySelect.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* A composable that provides key-based selection for an options map.
|
||||
* Extracts the repeated pattern of finding the current option's key from an
|
||||
* options object and selecting a new option by key.
|
||||
*
|
||||
* @param {Object} optionsMap - A static object mapping string keys to option objects
|
||||
* (each option should have at least `value` and `name` properties).
|
||||
* @param {() => any} getCurrentValue - A getter function that returns the currently
|
||||
* selected option value (e.g., `() => userDialog.value.worldSorting`).
|
||||
* @param {(option: any) => void} onSelect - Callback invoked when a new option is
|
||||
* selected by key. Receives the full option object.
|
||||
* @returns {{ selectedKey: import('vue').ComputedRef<string>, selectByKey: (key: string) => void }}
|
||||
*/
|
||||
export function useOptionKeySelect(optionsMap, getCurrentValue, onSelect) {
|
||||
const selectedKey = computed(() => {
|
||||
const current = getCurrentValue();
|
||||
const found = Object.entries(optionsMap).find(([, option]) => {
|
||||
if (option === current) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
option?.value === current?.value ||
|
||||
option?.name === current?.name
|
||||
);
|
||||
});
|
||||
return found ? String(found[0]) : '';
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
function selectByKey(key) {
|
||||
const option = optionsMap[key];
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
onSelect(option);
|
||||
}
|
||||
|
||||
return { selectedKey, selectByKey };
|
||||
}
|
||||
Reference in New Issue
Block a user