diff --git a/src/components/ui/tabs/TabsTrigger.vue b/src/components/ui/tabs/TabsTrigger.vue index a0164bfc..796d0aa2 100644 --- a/src/components/ui/tabs/TabsTrigger.vue +++ b/src/components/ui/tabs/TabsTrigger.vue @@ -21,7 +21,7 @@ data-slot="tabs-trigger" :class=" cn( - 'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', + 'data-[state=active]:bg-background data-[state=active]:border dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class ) " diff --git a/src/localization/en.json b/src/localization/en.json index 472e5eda..c6f0352e 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -221,6 +221,9 @@ "avatar": { "header": "Avatar", "search_provider": "Search Provider", + "no_provider": "No avatar search provider configured", + "search_placeholder_avatar": "Type at least 3 characters", + "min_chars_warning": "Please enter at least 3 characters to search", "refresh_tooltip": "Refresh own avatars", "result_count": "Results {count}", "all": "All", diff --git a/src/stores/avatarProvider.js b/src/stores/avatarProvider.js index 72106dbd..6bb6e61e 100644 --- a/src/stores/avatarProvider.js +++ b/src/stores/avatarProvider.js @@ -111,12 +111,8 @@ export const useAvatarProviderStore = defineStore('AvatarProvider', () => { } async function saveAvatarProviderList() { - const length = avatarRemoteDatabaseProviderList.value.length; - for (let i = 0; i < length; ++i) { - if (!avatarRemoteDatabaseProviderList.value[i]) { - avatarRemoteDatabaseProviderList.value.splice(i, 1); - } - } + avatarRemoteDatabaseProviderList.value = + avatarRemoteDatabaseProviderList.value.filter(Boolean); await configRepository.setString( 'VRCX_avatarRemoteDatabaseProviderList', JSON.stringify(avatarRemoteDatabaseProviderList.value) diff --git a/src/views/Search/Search.vue b/src/views/Search/Search.vue index e87a8dc9..5bee6063 100644 --- a/src/views/Search/Search.vue +++ b/src/views/Search/Search.vue @@ -1,87 +1,107 @@ diff --git a/src/views/Search/__tests__/Search.test.js b/src/views/Search/__tests__/Search.test.js new file mode 100644 index 00000000..8215f76d --- /dev/null +++ b/src/views/Search/__tests__/Search.test.js @@ -0,0 +1,380 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +const mocks = vi.hoisted(() => { + const { ref } = require('vue'); + return { + randomUserColours: ref(false), + avatarRemoteDatabaseProviderList: ref(['provider-a']), + avatarRemoteDatabaseProvider: ref('provider-a'), + isAvatarProviderDialogVisible: ref(false), + avatarRemoteDatabase: ref(true), + searchText: ref(''), + searchUserResults: ref([]), + cachedConfig: ref({ dynamicWorldRows: [] }), + clearSearch: vi.fn(), + setAvatarProvider: vi.fn(), + showAvatarDialog: vi.fn(), + showGroupDialog: vi.fn(), + showUserDialog: vi.fn(), + showWorldDialog: vi.fn(), + toastWarning: vi.fn(), + useSearchUserApi: null, + useSearchAvatarApi: null, + useSearchWorldApi: null, + useSearchGroupApi: null + }; +}); + +mocks.useSearchUserApi = { + searchUserParams: ref({ offset: 0 }), + searchUserByBio: ref(false), + searchUserSortByLastLoggedIn: ref(false), + isSearchUserLoading: ref(false), + searchUser: vi.fn(), + handleMoreSearchUser: vi.fn(), + clearUserSearch: vi.fn() +}; + +mocks.useSearchAvatarApi = { + searchAvatarPageNum: ref(0), + searchAvatarResults: ref([]), + searchAvatarPage: ref([]), + isSearchAvatarLoading: ref(false), + searchAvatar: vi.fn(), + moreSearchAvatar: vi.fn(), + clearAvatarSearch: vi.fn() +}; + +mocks.useSearchWorldApi = { + searchWorldLabs: ref(false), + searchWorldParams: ref({ offset: 0 }), + searchWorldCategoryIndex: ref(null), + searchWorldResults: ref([]), + isSearchWorldLoading: ref(false), + searchWorld: vi.fn(), + moreSearchWorld: vi.fn(), + handleSearchWorldCategorySelect: vi.fn(), + clearWorldSearch: vi.fn() +}; + +mocks.useSearchGroupApi = { + searchGroupParams: ref({ offset: 0 }), + searchGroupResults: ref([]), + isSearchGroupLoading: ref(false), + searchGroup: vi.fn(), + moreSearchGroup: vi.fn(), + clearGroupSearch: vi.fn() +}; + +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store) => store + }; +}); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key) => key + }) +})); + +vi.mock('vue-sonner', () => ({ + toast: { + warning: (...args) => mocks.toastWarning(...args) + } +})); + +vi.mock('@vueuse/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMagicKeys: () => ({}), + whenever: () => vi.fn() + }; +}); + +vi.mock('../../../stores', () => ({ + useAppearanceSettingsStore: () => ({ + randomUserColours: mocks.randomUserColours + }), + useAvatarProviderStore: () => ({ + avatarRemoteDatabaseProviderList: mocks.avatarRemoteDatabaseProviderList, + avatarRemoteDatabaseProvider: mocks.avatarRemoteDatabaseProvider, + isAvatarProviderDialogVisible: mocks.isAvatarProviderDialogVisible, + setAvatarProvider: (...args) => mocks.setAvatarProvider(...args) + }), + useAdvancedSettingsStore: () => ({ + avatarRemoteDatabase: mocks.avatarRemoteDatabase + }), + useSearchStore: () => ({ + searchText: mocks.searchText, + searchUserResults: mocks.searchUserResults, + clearSearch: (...args) => mocks.clearSearch(...args) + }), + useAuthStore: () => ({ + cachedConfig: mocks.cachedConfig + }) +})); + +vi.mock('../composables/useSearchUser', () => ({ + useSearchUser: () => mocks.useSearchUserApi +})); + +vi.mock('../composables/useSearchAvatar', () => ({ + useSearchAvatar: () => mocks.useSearchAvatarApi +})); + +vi.mock('../composables/useSearchWorld', () => ({ + useSearchWorld: () => mocks.useSearchWorldApi +})); + +vi.mock('../composables/useSearchGroup', () => ({ + useSearchGroup: () => mocks.useSearchGroupApi +})); + +vi.mock('../../../coordinators/avatarCoordinator', () => ({ + showAvatarDialog: (...args) => mocks.showAvatarDialog(...args) +})); + +vi.mock('../../../coordinators/groupCoordinator', () => ({ + showGroupDialog: (...args) => mocks.showGroupDialog(...args) +})); + +vi.mock('../../../coordinators/userCoordinator', () => ({ + showUserDialog: (...args) => mocks.showUserDialog(...args) +})); + +vi.mock('../../../coordinators/worldCoordinator', () => ({ + showWorldDialog: (...args) => mocks.showWorldDialog(...args) +})); + +vi.mock('../../../shared/utils', () => ({ + convertFileUrlToImageUrl: (url) => url, + languageClass: (lang) => `lang-${lang}`, + userImage: () => 'https://example.com/u.png' +})); + +vi.mock('@/components/ui/tabs', () => ({ + Tabs: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '
' + + '
' + }, + TabsList: { template: '
' }, + TabsTrigger: { props: ['value'], template: '' }, + TabsContent: { props: ['value'], template: '
' } +})); + +vi.mock('@/components/ui/button', () => ({ + Button: { + emits: ['click'], + template: '' + } +})); + +vi.mock('@/components/ui/checkbox', () => ({ + Checkbox: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: + '' + } +})); + +vi.mock('@/components/ui/input-group', () => ({ + InputGroupField: { + props: ['modelValue', 'placeholder'], + emits: ['input', 'keyup.enter'], + template: + '' + } +})); + +vi.mock('@/components/ui/select', () => ({ + Select: { template: '
' }, + SelectContent: { template: '
' }, + SelectGroup: { template: '
' }, + SelectItem: { template: '
' }, + SelectTrigger: { template: '
' }, + SelectValue: { template: '
' } +})); + +vi.mock('@/components/ui/item', () => ({ + Item: { + emits: ['click'], + template: '
' + }, + ItemGroup: { template: '
' }, + ItemHeader: { template: '
' }, + ItemMedia: { template: '
' }, + ItemContent: { template: '
' }, + ItemTitle: { template: '
' }, + ItemDescription: { template: '
' } +})); + +vi.mock('@/components/ui/avatar', () => ({ + Avatar: { template: '
' }, + AvatarImage: { template: '' }, + AvatarFallback: { template: '' } +})); + +vi.mock('@/components/ui/data-table', () => ({ + DataTableEmpty: { template: '
empty
' } +})); + +vi.mock('@/components/ui/spinner', () => ({ + Spinner: { template: '' } +})); + +vi.mock('@/components/ui/tooltip', () => ({ + TooltipWrapper: { template: '
' } +})); + +vi.mock('lucide-vue-next', () => ({ + Settings: { template: '' }, + Trash2: { template: '' }, + User: { template: '' }, + Users: { template: '' } +})); + +import SearchView from '../Search.vue'; + +function mountSearch() { + return mount(SearchView, { + global: { + stubs: { + TooltipWrapper: { template: '
' }, + AvatarProviderDialog: { template: '
' }, + SearchPagination: { + props: ['show', 'prevDisabled', 'nextDisabled'], + emits: ['prev', 'next'], + template: '
' + } + } + } + }); +} + +describe('Search.vue', () => { + beforeEach(() => { + mocks.searchText.value = ''; + mocks.searchUserResults.value = []; + mocks.randomUserColours.value = false; + + mocks.useSearchUserApi.searchUserParams.value = { offset: 0 }; + mocks.useSearchUserApi.searchUserByBio.value = false; + mocks.useSearchUserApi.searchUserSortByLastLoggedIn.value = false; + mocks.useSearchUserApi.isSearchUserLoading.value = false; + + mocks.useSearchAvatarApi.searchAvatarPageNum.value = 0; + mocks.useSearchAvatarApi.searchAvatarResults.value = []; + mocks.useSearchAvatarApi.searchAvatarPage.value = []; + mocks.useSearchAvatarApi.isSearchAvatarLoading.value = false; + + mocks.useSearchWorldApi.searchWorldParams.value = { offset: 0 }; + mocks.useSearchWorldApi.searchWorldResults.value = []; + mocks.useSearchWorldApi.isSearchWorldLoading.value = false; + + mocks.useSearchGroupApi.searchGroupParams.value = { offset: 0 }; + mocks.useSearchGroupApi.searchGroupResults.value = []; + mocks.useSearchGroupApi.isSearchGroupLoading.value = false; + + mocks.clearSearch.mockReset(); + mocks.toastWarning.mockReset(); + mocks.showUserDialog.mockReset(); + mocks.showGroupDialog.mockReset(); + + mocks.useSearchUserApi.searchUser.mockReset(); + mocks.useSearchUserApi.clearUserSearch.mockReset(); + mocks.useSearchAvatarApi.searchAvatar.mockReset(); + mocks.useSearchAvatarApi.clearAvatarSearch.mockReset(); + mocks.useSearchWorldApi.searchWorld.mockReset(); + mocks.useSearchWorldApi.clearWorldSearch.mockReset(); + mocks.useSearchGroupApi.searchGroup.mockReset(); + mocks.useSearchGroupApi.clearGroupSearch.mockReset(); + }); + + it('clears all tab searches from toolbar clear button', async () => { + const wrapper = mountSearch(); + + await wrapper.get('button.ml-2').trigger('click'); + + expect(mocks.useSearchUserApi.clearUserSearch).toHaveBeenCalledTimes(1); + expect(mocks.useSearchWorldApi.clearWorldSearch).toHaveBeenCalledTimes(1); + expect(mocks.useSearchAvatarApi.clearAvatarSearch).toHaveBeenCalledTimes(1); + expect(mocks.useSearchGroupApi.clearGroupSearch).toHaveBeenCalledTimes(1); + expect(mocks.clearSearch).toHaveBeenCalledTimes(1); + }); + + it('runs user search on Enter when active tab is user', async () => { + const wrapper = mountSearch(); + + await wrapper.get('[data-testid="search-input"]').trigger('keyup.enter'); + + expect(mocks.useSearchUserApi.searchUser).toHaveBeenCalledTimes(1); + expect(mocks.useSearchAvatarApi.searchAvatar).not.toHaveBeenCalled(); + }); + + it('shows avatar minimum length warning and skips avatar search', async () => { + const wrapper = mountSearch(); + mocks.searchText.value = 'ab'; + + await wrapper.get('[data-testid="set-tab-avatar"]').trigger('click'); + await wrapper.get('[data-testid="search-input"]').trigger('keyup.enter'); + + expect(mocks.toastWarning).toHaveBeenCalledWith('view.search.avatar.min_chars_warning'); + expect(mocks.useSearchAvatarApi.searchAvatar).not.toHaveBeenCalled(); + }); + + it('opens user dialog when clicking a user item', async () => { + mocks.searchUserResults.value = [ + { + id: 'usr_1', + displayName: 'Alice', + bio: 'Hi', + $trustLevel: 'Known User', + $trustClass: 'text-green', + $userColour: '#fff', + $languages: [] + } + ]; + + const wrapper = mountSearch(); + + await wrapper.find('.item').trigger('click'); + + expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_1'); + }); + + it('opens group dialog when clicking a group item', async () => { + mocks.useSearchGroupApi.searchGroupResults.value = [ + { + id: 'grp_1', + iconUrl: 'https://example.com/group.png', + name: 'Group One', + memberCount: 12, + shortCode: 'AB', + discriminator: '1234', + description: 'desc' + } + ]; + + const wrapper = mountSearch(); + + await wrapper.get('[data-testid="set-tab-group"]').trigger('click'); + const items = wrapper.findAll('.item'); + await items[items.length - 1].trigger('click'); + + expect(mocks.showGroupDialog).toHaveBeenCalledWith('grp_1'); + }); +}); diff --git a/src/views/Search/components/SearchPagination.vue b/src/views/Search/components/SearchPagination.vue new file mode 100644 index 00000000..ace8a321 --- /dev/null +++ b/src/views/Search/components/SearchPagination.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/views/Search/composables/__tests__/useSearchAvatar.test.js b/src/views/Search/composables/__tests__/useSearchAvatar.test.js new file mode 100644 index 00000000..89923272 --- /dev/null +++ b/src/views/Search/composables/__tests__/useSearchAvatar.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +const mocks = vi.hoisted(() => ({ + avatarRemoteDatabase: require('vue').ref(true), + searchText: require('vue').ref(''), + lookupAvatars: vi.fn() +})); + +vi.mock('pinia', () => ({ + storeToRefs: (store) => store +})); + +vi.mock('../../../../stores', () => ({ + useAdvancedSettingsStore: () => ({ + avatarRemoteDatabase: mocks.avatarRemoteDatabase + }), + useSearchStore: () => ({ + searchText: mocks.searchText + }) +})); + +vi.mock('../../../../coordinators/avatarCoordinator', () => ({ + lookupAvatars: (...args) => mocks.lookupAvatars(...args) +})); + +import { useSearchAvatar } from '../useSearchAvatar'; + +describe('useSearchAvatar', () => { + beforeEach(() => { + mocks.avatarRemoteDatabase.value = true; + mocks.searchText.value = ''; + mocks.lookupAvatars.mockReset(); + }); + + it('queries remote avatars and builds first page', async () => { + mocks.searchText.value = 'alice'; + mocks.lookupAvatars.mockResolvedValue([ + { id: 'avtr_1', name: 'A' }, + { id: 'avtr_1', name: 'A-dup' }, + { id: 'avtr_2', name: 'B' } + ]); + + const api = useSearchAvatar(); + await api.searchAvatar(); + + expect(mocks.lookupAvatars).toHaveBeenCalledWith('search', 'alice'); + expect(api.searchAvatarResults.value.map((x) => x.id)).toEqual(['avtr_1', 'avtr_2']); + expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual(['avtr_1', 'avtr_2']); + expect(api.searchAvatarPageNum.value).toBe(0); + }); + + it('skips remote query when text is too short', async () => { + mocks.searchText.value = 'ab'; + const api = useSearchAvatar(); + + await api.searchAvatar(); + + expect(mocks.lookupAvatars).not.toHaveBeenCalled(); + expect(api.searchAvatarResults.value).toEqual([]); + }); + + it('paginates results by 10 items', () => { + const api = useSearchAvatar(); + api.searchAvatarResults.value = Array.from({ length: 25 }, (_, i) => ({ id: `avtr_${i}` })); + api.searchAvatarPage.value = api.searchAvatarResults.value.slice(0, 10); + + api.moreSearchAvatar(1); + expect(api.searchAvatarPageNum.value).toBe(1); + expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual( + Array.from({ length: 10 }, (_, i) => `avtr_${i + 10}`) + ); + + api.moreSearchAvatar(-1); + expect(api.searchAvatarPageNum.value).toBe(0); + expect(api.searchAvatarPage.value.map((x) => x.id)).toEqual( + Array.from({ length: 10 }, (_, i) => `avtr_${i}`) + ); + }); +}); diff --git a/src/views/Search/composables/__tests__/useSearchGroup.test.js b/src/views/Search/composables/__tests__/useSearchGroup.test.js new file mode 100644 index 00000000..99443843 --- /dev/null +++ b/src/views/Search/composables/__tests__/useSearchGroup.test.js @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ + searchText: require('vue').ref(''), + replaceBioSymbols: vi.fn((text) => text), + groupSearch: vi.fn() +})); + +vi.mock('pinia', () => ({ + storeToRefs: (store) => store +})); + +vi.mock('../../../../stores', () => ({ + useSearchStore: () => ({ + searchText: mocks.searchText + }) +})); + +vi.mock('../../../../shared/utils', () => ({ + replaceBioSymbols: (...args) => mocks.replaceBioSymbols(...args) +})); + +vi.mock('../../../../api', () => ({ + groupRequest: { + groupSearch: (...args) => mocks.groupSearch(...args) + } +})); + +import { useSearchGroup } from '../useSearchGroup'; + +describe('useSearchGroup', () => { + beforeEach(() => { + mocks.searchText.value = ''; + mocks.replaceBioSymbols.mockReset(); + mocks.replaceBioSymbols.mockImplementation((text) => text); + mocks.groupSearch.mockReset(); + }); + + it('starts group search with normalized query', async () => { + mocks.searchText.value = 'group+name'; + mocks.replaceBioSymbols.mockReturnValue('group name'); + mocks.groupSearch.mockResolvedValue({ json: [{ id: 'grp_1' }, { id: 'grp_1' }, { id: 'grp_2' }] }); + + const api = useSearchGroup(); + await api.searchGroup(); + + expect(mocks.replaceBioSymbols).toHaveBeenCalledWith('group+name'); + expect(mocks.groupSearch).toHaveBeenCalledWith({ + n: 10, + offset: 0, + query: 'group name' + }); + expect(api.searchGroupResults.value.map((x) => x.id)).toEqual(['grp_1', 'grp_2']); + }); + + it('moves backward paging offset without going below zero', async () => { + mocks.groupSearch.mockResolvedValue({ json: [] }); + const api = useSearchGroup(); + api.searchGroupParams.value = { n: 10, offset: 5, query: 'abc' }; + + await api.moreSearchGroup(-1); + + expect(mocks.groupSearch).toHaveBeenCalledWith({ n: 10, offset: 0, query: 'abc' }); + }); +}); diff --git a/src/views/Search/composables/__tests__/useSearchUser.test.js b/src/views/Search/composables/__tests__/useSearchUser.test.js new file mode 100644 index 00000000..662f9a40 --- /dev/null +++ b/src/views/Search/composables/__tests__/useSearchUser.test.js @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ + searchText: require('vue').ref(''), + moreSearchUser: vi.fn(() => Promise.resolve()) +})); + +vi.mock('pinia', () => ({ + storeToRefs: (store) => store +})); + +vi.mock('../../../../stores', () => ({ + useSearchStore: () => ({ + searchText: mocks.searchText, + moreSearchUser: (...args) => mocks.moreSearchUser(...args) + }) +})); + +import { useSearchUser } from '../useSearchUser'; + +describe('useSearchUser', () => { + beforeEach(() => { + mocks.searchText.value = ''; + mocks.moreSearchUser.mockReset(); + mocks.moreSearchUser.mockResolvedValue(undefined); + }); + + it('builds search params and requests first page', async () => { + mocks.searchText.value = 'Alice'; + const api = useSearchUser(); + api.searchUserByBio.value = true; + api.searchUserSortByLastLoggedIn.value = true; + + await api.searchUser(); + + expect(mocks.moreSearchUser).toHaveBeenCalledWith(null, { + n: 10, + offset: 0, + search: 'Alice', + customFields: 'bio', + sort: 'last_login' + }); + expect(api.isSearchUserLoading.value).toBe(false); + }); + + it('passes page direction into handleMoreSearchUser', async () => { + const api = useSearchUser(); + api.searchUserParams.value = { n: 10, offset: 10, search: 'Alice', customFields: 'displayName', sort: 'relevance' }; + + await api.handleMoreSearchUser(-1); + + expect(mocks.moreSearchUser).toHaveBeenCalledWith(-1, api.searchUserParams.value); + }); +}); diff --git a/src/views/Search/composables/__tests__/useSearchWorld.test.js b/src/views/Search/composables/__tests__/useSearchWorld.test.js new file mode 100644 index 00000000..bb59b550 --- /dev/null +++ b/src/views/Search/composables/__tests__/useSearchWorld.test.js @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ + searchText: require('vue').ref(''), + cachedConfig: require('vue').ref({ dynamicWorldRows: [] }), + cachedWorlds: new Map(), + replaceBioSymbols: vi.fn((text) => text), + getWorlds: vi.fn() +})); + +vi.mock('pinia', () => ({ + storeToRefs: (store) => store +})); + +vi.mock('../../../../stores', () => ({ + useSearchStore: () => ({ + searchText: mocks.searchText + }), + useAuthStore: () => ({ + cachedConfig: mocks.cachedConfig + }), + useWorldStore: () => ({ + cachedWorlds: mocks.cachedWorlds + }) +})); + +vi.mock('../../../../shared/utils', () => ({ + replaceBioSymbols: (...args) => mocks.replaceBioSymbols(...args) +})); + +vi.mock('../../../../api', () => ({ + worldRequest: { + getWorlds: (...args) => mocks.getWorlds(...args) + } +})); + +import { useSearchWorld } from '../useSearchWorld'; + +describe('useSearchWorld', () => { + beforeEach(() => { + mocks.searchText.value = ''; + mocks.cachedConfig.value = { dynamicWorldRows: [] }; + mocks.cachedWorlds.clear(); + mocks.replaceBioSymbols.mockReset(); + mocks.replaceBioSymbols.mockImplementation((text) => text); + mocks.getWorlds.mockReset(); + }); + + it('creates relevance params and appends system_approved tag', async () => { + mocks.searchText.value = 'home world'; + mocks.replaceBioSymbols.mockReturnValue('home world'); + mocks.cachedWorlds.set('wrld_1', { id: 'wrld_1', name: 'World One' }); + mocks.getWorlds.mockResolvedValue({ json: [{ id: 'wrld_1' }, { id: 'wrld_missing' }] }); + + const api = useSearchWorld(); + api.searchWorld({}); + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.getWorlds).toHaveBeenCalledWith( + { + n: 10, + offset: 0, + sort: 'relevance', + search: 'home world', + order: 'descending', + tag: 'system_approved' + }, + '' + ); + expect(api.searchWorldParams.value.search).toBe('home world'); + }); + + it('selects category row and uses row sort settings', async () => { + mocks.cachedConfig.value = { + dynamicWorldRows: [{ index: 2, sortHeading: 'featured', sortOrder: 'ascending', tag: 'party' }] + }; + mocks.getWorlds.mockResolvedValue({ json: [] }); + + const api = useSearchWorld(); + api.handleSearchWorldCategorySelect(2); + await Promise.resolve(); + await Promise.resolve(); + + expect(api.searchWorldCategoryIndex.value).toBe(2); + expect(mocks.getWorlds).toHaveBeenCalledWith( + { + n: 10, + offset: 0, + sort: 'order', + featured: 'true', + order: 'ascending', + tag: 'party,system_approved' + }, + '' + ); + }); +}); diff --git a/src/views/Search/composables/useSearchAvatar.js b/src/views/Search/composables/useSearchAvatar.js index 1db97e03..1bbcda8f 100644 --- a/src/views/Search/composables/useSearchAvatar.js +++ b/src/views/Search/composables/useSearchAvatar.js @@ -1,29 +1,17 @@ import { ref } from 'vue'; import { storeToRefs } from 'pinia'; -import { - compareByCreatedAt, - compareByName, - compareByUpdatedAt -} from '../../../shared/utils'; -import { - useAdvancedSettingsStore, - useAvatarStore, - useSearchStore -} from '../../../stores'; +import { useAdvancedSettingsStore, useSearchStore } from '../../../stores'; +import { lookupAvatars } from '../../../coordinators/avatarCoordinator'; /** * Avatar search composable for Search view. - * Manages avatar search state, local/remote filtering, sorting, and pagination. + * Searches remote avatar databases only (local avatar browsing is handled by My Avatars page). */ export function useSearchAvatar() { const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore()); - const { lookupAvatars, cachedAvatars } = useAvatarStore(); const { searchText } = storeToRefs(useSearchStore()); - const searchAvatarFilter = ref(''); - const searchAvatarSort = ref(''); - const searchAvatarFilterRemote = ref(''); const searchAvatarPageNum = ref(0); const searchAvatarResults = ref([]); const searchAvatarPage = ref([]); @@ -33,111 +21,26 @@ export function useSearchAvatar() { * */ async function searchAvatar() { - let ref; isSearchAvatarLoading.value = true; - if (!searchAvatarFilter.value) { - searchAvatarFilter.value = 'all'; - } - if (!searchAvatarSort.value) { - searchAvatarSort.value = 'name'; - } - if (!searchAvatarFilterRemote.value) { - searchAvatarFilterRemote.value = 'all'; - } - if (searchAvatarFilterRemote.value !== 'local') { - searchAvatarSort.value = 'name'; - } const avatars = new Map(); const query = searchText.value; - const queryUpper = query.toUpperCase(); - if (!query) { - for (ref of cachedAvatars.values()) { - switch (searchAvatarFilter.value) { - case 'all': - avatars.set(ref.id, ref); - break; - case 'public': - if (ref.releaseStatus === 'public') { - avatars.set(ref.id, ref); - } - break; - case 'private': - if (ref.releaseStatus === 'private') { - avatars.set(ref.id, ref); - } - break; - } + + if (query && query.length >= 3 && avatarRemoteDatabase.value) { + const data = await lookupAvatars('search', query); + if (data && typeof data === 'object') { + data.forEach((avatar) => { + avatars.set(avatar.id, avatar); + }); } - isSearchAvatarLoading.value = false; - } else { - if ( - searchAvatarFilterRemote.value === 'all' || - searchAvatarFilterRemote.value === 'local' - ) { - for (ref of cachedAvatars.values()) { - let match = ref.name.toUpperCase().includes(queryUpper); - if (!match && ref.description) { - match = ref.description - .toUpperCase() - .includes(queryUpper); - } - if (!match && ref.authorName) { - match = ref.authorName - .toUpperCase() - .includes(queryUpper); - } - if (match) { - switch (searchAvatarFilter.value) { - case 'all': - avatars.set(ref.id, ref); - break; - case 'public': - if (ref.releaseStatus === 'public') { - avatars.set(ref.id, ref); - } - break; - case 'private': - if (ref.releaseStatus === 'private') { - avatars.set(ref.id, ref); - } - break; - } - } - } - } - if ( - (searchAvatarFilterRemote.value === 'all' || - searchAvatarFilterRemote.value === 'remote') && - avatarRemoteDatabase.value && - query.length >= 3 - ) { - const data = await lookupAvatars('search', query); - if (data && typeof data === 'object') { - data.forEach((avatar) => { - avatars.set(avatar.id, avatar); - }); - } - } - isSearchAvatarLoading.value = false; } + + isSearchAvatarLoading.value = false; const avatarsArray = Array.from(avatars.values()); - if (searchAvatarFilterRemote.value === 'local') { - switch (searchAvatarSort.value) { - case 'updated': - avatarsArray.sort(compareByUpdatedAt); - break; - case 'created': - avatarsArray.sort(compareByCreatedAt); - break; - case 'name': - avatarsArray.sort(compareByName); - break; - } - } searchAvatarPageNum.value = 0; searchAvatarResults.value = avatarsArray; searchAvatarPage.value = avatarsArray.slice(0, 10); } + /** * * @param n @@ -158,33 +61,6 @@ export function useSearchAvatar() { ); } - /** - * - * @param value - */ - function handleSearchAvatarFilterChange(value) { - searchAvatarFilter.value = value; - searchAvatar(); - } - - /** - * - * @param value - */ - function handleSearchAvatarFilterRemoteChange(value) { - searchAvatarFilterRemote.value = value; - searchAvatar(); - } - - /** - * - * @param value - */ - function handleSearchAvatarSortChange(value) { - searchAvatarSort.value = value; - searchAvatar(); - } - /** * */ @@ -195,18 +71,12 @@ export function useSearchAvatar() { } return { - searchAvatarFilter, - searchAvatarSort, - searchAvatarFilterRemote, searchAvatarPageNum, searchAvatarResults, searchAvatarPage, isSearchAvatarLoading, searchAvatar, moreSearchAvatar, - handleSearchAvatarFilterChange, - handleSearchAvatarFilterRemoteChange, - handleSearchAvatarSortChange, clearAvatarSearch }; } diff --git a/src/views/Search/composables/useSearchGroup.js b/src/views/Search/composables/useSearchGroup.js new file mode 100644 index 00000000..d1843c4c --- /dev/null +++ b/src/views/Search/composables/useSearchGroup.js @@ -0,0 +1,75 @@ +import { ref } from 'vue'; +import { storeToRefs } from 'pinia'; + +import { useSearchStore } from '../../../stores'; +import { replaceBioSymbols } from '../../../shared/utils'; +import { groupRequest } from '../../../api'; + +/** + * Group search composable for Search view. + * Manages group search state and pagination. + */ +export function useSearchGroup() { + const { searchText } = storeToRefs(useSearchStore()); + + const searchGroupParams = ref({}); + const searchGroupResults = ref([]); + const isSearchGroupLoading = ref(false); + + /** + * + */ + async function searchGroup() { + searchGroupParams.value = { + n: 10, + offset: 0, + query: replaceBioSymbols(searchText.value) + }; + await moreSearchGroup(); + } + + /** + * + * @param go + */ + async function moreSearchGroup(go) { + const params = searchGroupParams.value; + if (go) { + params.offset += params.n * go; + if (params.offset < 0) { + params.offset = 0; + } + } + isSearchGroupLoading.value = true; + await groupRequest + .groupSearch(params) + .finally(() => { + isSearchGroupLoading.value = false; + }) + .then((args) => { + const map = new Map(); + for (const json of args.json) { + map.set(json.id, json); + } + searchGroupResults.value = Array.from(map.values()); + return args; + }); + } + + /** + * + */ + function clearGroupSearch() { + searchGroupParams.value = {}; + searchGroupResults.value = []; + } + + return { + searchGroupParams, + searchGroupResults, + isSearchGroupLoading, + searchGroup, + moreSearchGroup, + clearGroupSearch + }; +} diff --git a/src/views/Search/composables/useSearchUser.js b/src/views/Search/composables/useSearchUser.js new file mode 100644 index 00000000..efabc698 --- /dev/null +++ b/src/views/Search/composables/useSearchUser.js @@ -0,0 +1,61 @@ +import { ref } from 'vue'; +import { storeToRefs } from 'pinia'; + +import { useSearchStore } from '../../../stores'; + +/** + * User search composable for Search view. + * Manages user search state, filters, and pagination. + */ +export function useSearchUser() { + const { searchText } = storeToRefs(useSearchStore()); + const { moreSearchUser } = useSearchStore(); + + const searchUserParams = ref({}); + const searchUserByBio = ref(false); + const searchUserSortByLastLoggedIn = ref(false); + const isSearchUserLoading = ref(false); + + /** + * + */ + async function searchUser() { + searchUserParams.value = { + n: 10, + offset: 0, + search: searchText.value, + customFields: searchUserByBio.value ? 'bio' : 'displayName', + sort: searchUserSortByLastLoggedIn.value + ? 'last_login' + : 'relevance' + }; + await handleMoreSearchUser(); + } + + /** + * + * @param go + */ + async function handleMoreSearchUser(go = null) { + isSearchUserLoading.value = true; + await moreSearchUser(go, searchUserParams.value); + isSearchUserLoading.value = false; + } + + /** + * + */ + function clearUserSearch() { + searchUserParams.value = {}; + } + + return { + searchUserParams, + searchUserByBio, + searchUserSortByLastLoggedIn, + isSearchUserLoading, + searchUser, + handleMoreSearchUser, + clearUserSearch + }; +} diff --git a/src/views/Settings/dialogs/AvatarProviderDialog.vue b/src/views/Settings/dialogs/AvatarProviderDialog.vue index 4f06e747..7e9475cf 100644 --- a/src/views/Settings/dialogs/AvatarProviderDialog.vue +++ b/src/views/Settings/dialogs/AvatarProviderDialog.vue @@ -19,7 +19,7 @@ -
@@ -59,4 +59,11 @@ function closeDialog() { emit('update:isAvatarProviderDialogVisible', false); } + + /** + * + */ + function addProvider() { + avatarRemoteDatabaseProviderList.value.push(''); + }