diff --git a/packages/x-components/src/composables/__tests__/use-alias-api.spec.ts b/packages/x-components/src/composables/__tests__/use-alias-api.spec.ts new file mode 100644 index 0000000000..bf30279753 --- /dev/null +++ b/packages/x-components/src/composables/__tests__/use-alias-api.spec.ts @@ -0,0 +1,230 @@ +import Vue, { defineComponent } from 'vue'; +import { createLocalVue, mount, Wrapper } from '@vue/test-utils'; +import Vuex, { Store } from 'vuex'; +import { AnyXStoreModule } from '../../store/index'; +import { UseAliasAPI, useAliasApi, UseAliasQueryAPI, UseAliasStatusAPI } from '../use-alias-api'; +import { searchBoxXStoreModule } from '../../x-modules/search-box/index'; +import { nextQueriesXStoreModule } from '../../x-modules/next-queries/index'; +import { querySuggestionsXStoreModule } from '../../x-modules/query-suggestions/index'; +import { relatedTagsXStoreModule } from '../../x-modules/related-tags/index'; +import { searchXStoreModule } from '../../x-modules/search/index'; +import { facetsXStoreModule } from '../../x-modules/facets/index'; +import { identifierResultsXStoreModule } from '../../x-modules/identifier-results/index'; +import { popularSearchesXStoreModule } from '../../x-modules/popular-searches/index'; +import { recommendationsXStoreModule } from '../../x-modules/recommendations/index'; +import { historyQueriesXStoreModule } from '../../x-modules/history-queries/index'; + +const renderUseAliasApiTest = (registerXModules = true): renderUseAliasApiTestAPI => { + const testComponent = defineComponent({ + setup() { + const xAliasAPI = useAliasApi(); + const query = xAliasAPI.query; + const status = xAliasAPI.status; + return { + query, + status, + xAliasAPI + }; + }, + template: '
' + }); + + const localVue = createLocalVue(); + localVue.use(Vuex); + + const store = new Store({ + modules: { + x: { + namespaced: true, + modules: registerXModules + ? { + searchBox: { namespaced: true, ...searchBoxXStoreModule } as AnyXStoreModule, + nextQueries: { namespaced: true, ...nextQueriesXStoreModule } as AnyXStoreModule, + querySuggestions: { + namespaced: true, + ...querySuggestionsXStoreModule + } as AnyXStoreModule, + relatedTags: { namespaced: true, ...relatedTagsXStoreModule } as AnyXStoreModule, + search: { namespaced: true, ...searchXStoreModule } as AnyXStoreModule, + facets: { namespaced: true, ...facetsXStoreModule } as AnyXStoreModule, + historyQueries: { + namespaced: true, + ...historyQueriesXStoreModule + } as AnyXStoreModule, + identifierResults: { + namespaced: true, + ...identifierResultsXStoreModule + } as AnyXStoreModule, + popularSearches: { + namespaced: true, + ...popularSearchesXStoreModule + } as AnyXStoreModule, + recommendations: { + namespaced: true, + ...recommendationsXStoreModule + } as AnyXStoreModule + } + : {} + } + } + }); + + const wrapper = mount(testComponent, { + localVue, + store + }); + + return { + store, + wrapper, + query: (wrapper.vm as any).query, + status: (wrapper.vm as any).status, + xAliasAPI: (wrapper.vm as any).xAliasAPI + }; +}; +describe('testing useAliasApi composable', () => { + it('returns default values when no module is registered', () => { + const { xAliasAPI } = renderUseAliasApiTest(false); + + const defaultValues = { + query: { + facets: '', + searchBox: '', + nextQueries: '', + querySuggestions: '', + relatedTags: '', + search: '' + }, + status: { + identifierResults: undefined, + nextQueries: undefined, + popularSearches: undefined, + querySuggestions: undefined, + recommendations: undefined, + relatedTags: undefined, + search: undefined + }, + device: null, + facets: {}, + historyQueries: [], + historyQueriesWithResults: [], + fullHistoryQueries: [], + isHistoryQueriesEnabled: false, + fromNoResultsWithFilters: false, + identifierResults: [], + searchBoxStatus: undefined, + isEmpathizeOpen: false, + nextQueries: [], + noResults: false, + partialResults: [], + popularSearches: [], + querySuggestions: [], + fullQuerySuggestions: [], + recommendations: [], + redirections: [], + relatedTags: [], + results: [], + scroll: {}, + selectedFilters: [], + selectedRelatedTags: [], + semanticQueries: [], + spellcheckedQuery: null, + totalResults: 0, + selectedSort: '' + }; + expect(xAliasAPI).toMatchObject(defaultValues); + }); + it('updates the query values when the module is registered', () => { + const { store, query } = renderUseAliasApiTest(); + + expect(query).toEqual({ + searchBox: '', + nextQueries: '', + querySuggestions: '', + relatedTags: '', + search: '', + facets: '' + }); + + store.commit('x/searchBox/setQuery', 'salchichón'); + store.commit('x/nextQueries/setQuery', 'chorizo'); + store.commit('x/querySuggestions/setQuery', 'lomo'); + store.commit('x/relatedTags/setQuery', 'jamón'); + store.commit('x/search/setQuery', 'cecina'); + store.commit('x/facets/setQuery', 'mortadela'); + store.commit('x/historyQueries/setQuery', 'queso'); + + expect(query).toEqual({ + searchBox: 'salchichón', + nextQueries: 'chorizo', + querySuggestions: 'lomo', + relatedTags: 'jamón', + search: 'cecina', + facets: 'mortadela' + }); + }); + it('updates the status values when the module is registered', () => { + const REQUEST_STATUS_REGEX = /success|loading|error|initial/; + const { status } = renderUseAliasApiTest(); + + expect(status).toEqual({ + identifierResults: expect.stringMatching(REQUEST_STATUS_REGEX), + popularSearches: expect.stringMatching(REQUEST_STATUS_REGEX), + recommendations: expect.stringMatching(REQUEST_STATUS_REGEX), + nextQueries: expect.stringMatching(REQUEST_STATUS_REGEX), + querySuggestions: expect.stringMatching(REQUEST_STATUS_REGEX), + relatedTags: expect.stringMatching(REQUEST_STATUS_REGEX), + search: expect.stringMatching(REQUEST_STATUS_REGEX) + }); + }); + it('reacts dynamically to referenced values changing', () => { + const { store, xAliasAPI } = renderUseAliasApiTest(); + expect(xAliasAPI.historyQueries[0]).toBeUndefined(); + + store.dispatch('x/historyQueries/addQueryToHistory', 'chorizo'); + + expect(xAliasAPI.historyQueries[0].query).toEqual('chorizo'); + }); + it('has every property defined as a getter', () => { + const { xAliasAPI } = renderUseAliasApiTest(); + /** + * Checks that every property defined by the object and keys is a getter or an object that + * only contains getters. + * + * @param obj - The object to check. + * @param keys - The subset of keys from the object to check. + * @returns True when the object properties defined by the keys are getters or object with + * getters. + */ + function isJSGetterOrDictionaryOfJSGetters( + // object and string[] are the parameters used by getOwnPropertyDescriptor. + // eslint-disable-next-line @typescript-eslint/ban-types + obj: object, + keys: string[] + ): boolean { + return keys.every(key => { + const descriptor = Object.getOwnPropertyDescriptor(obj, key); + const value = obj[key as keyof typeof obj]; + return ( + (descriptor?.set === undefined && + descriptor?.value === undefined && + descriptor?.get !== undefined) || + (typeof value === 'object' && + isJSGetterOrDictionaryOfJSGetters(value, Object.keys(value))) + ); + }); + } + + const aliasKeys = Object.keys(xAliasAPI); + + expect(isJSGetterOrDictionaryOfJSGetters(xAliasAPI, aliasKeys)).toEqual(true); + }); +}); + +type renderUseAliasApiTestAPI = { + store: Store; + wrapper: Wrapper; + query: UseAliasQueryAPI; + status: UseAliasStatusAPI; + xAliasAPI: UseAliasAPI; +}; diff --git a/packages/x-components/src/composables/index.ts b/packages/x-components/src/composables/index.ts index f712b88054..141a198abc 100644 --- a/packages/x-components/src/composables/index.ts +++ b/packages/x-components/src/composables/index.ts @@ -7,3 +7,4 @@ export * from './use-store'; export * from './use-state'; export * from './use-getter'; export * from './use-hybrid-inject'; +export * from './use-alias-api'; diff --git a/packages/x-components/src/composables/use-alias-api.ts b/packages/x-components/src/composables/use-alias-api.ts new file mode 100644 index 0000000000..9c188f1c29 --- /dev/null +++ b/packages/x-components/src/composables/use-alias-api.ts @@ -0,0 +1,258 @@ +import { + Facet, + Filter, + HistoryQuery, + NextQuery, + PartialResult, + Redirection, + RelatedTag, + Result, + SemanticQuery, + Suggestion +} from '@empathyco/x-types'; +import { ScrollComponentState } from '../x-modules/scroll/store/types'; +import { InputStatus } from '../x-modules/search-box/store/types'; +import { RequestStatus } from '../store/utils/status-store.utils'; +import { getGetterPath } from '../plugins/index'; +import { useStore } from './use-store'; + +/** + * Creates an object containing the alias part of {@link XComponentAPI}. + * + * @returns An object containing the alias part of the {@link XComponentAPI}. + * + * @internal + */ +export function useAliasApi(this: any): UseAliasAPI { + const queryModules = [ + 'facets', + 'searchBox', + 'nextQueries', + 'querySuggestions', + 'relatedTags', + 'search' + ] as const; + const statusModules = [ + 'identifierResults', + 'nextQueries', + 'popularSearches', + 'querySuggestions', + 'recommendations', + 'relatedTags', + 'search' + ] as const; + + const store = useStore(); + + const query = queryModules.reduce((acc, moduleName) => { + return Object.defineProperty(acc, moduleName, { + get(): string { + return store.state.x[moduleName]?.query ?? ''; + }, + enumerable: true + }); + }, {} as UseAliasQueryAPI); + + const status = statusModules.reduce((acc, moduleName) => { + return Object.defineProperty(acc, moduleName, { + get(): RequestStatus | undefined { + return store.state.x[moduleName]?.status; + }, + enumerable: true + }); + }, {} as UseAliasStatusAPI); + + return { + query, + status, + get device() { + return store.state.x.device?.name ?? null; + }, + get facets() { + return store.getters[getGetterPath('facets', 'facets')] ?? {}; + }, + get historyQueries() { + return store.getters[getGetterPath('historyQueries', 'historyQueries')] ?? []; + }, + get historyQueriesWithResults() { + return store.getters[getGetterPath('historyQueries', 'historyQueriesWithResults')] ?? []; + }, + get fullHistoryQueries() { + return store.state.x.historyQueries?.historyQueries ?? []; + }, + get isHistoryQueriesEnabled() { + return store.state.x.historyQueries?.isEnabled ?? false; + }, + get fromNoResultsWithFilters() { + return store.state.x.search?.fromNoResultsWithFilters ?? false; + }, + get identifierResults() { + return store.state.x.identifierResults?.identifierResults ?? []; + }, + get searchBoxStatus() { + return store.state.x.searchBox?.inputStatus ?? undefined; + }, + get isEmpathizeOpen() { + return store.state.x.empathize?.isOpen ?? false; + }, + get nextQueries() { + return store.getters[getGetterPath('nextQueries', 'nextQueries')] ?? []; + }, + get noResults() { + return store.state.x.search?.isNoResults ?? false; + }, + get partialResults() { + return store.state.x.search?.partialResults ?? []; + }, + get popularSearches() { + return store.state.x.popularSearches?.popularSearches ?? []; + }, + get querySuggestions() { + return store.getters[getGetterPath('querySuggestions', 'querySuggestions')] ?? []; + }, + get fullQuerySuggestions() { + return store.state.x.querySuggestions?.suggestions ?? []; + }, + get recommendations() { + return store.state.x.recommendations?.recommendations ?? []; + }, + get redirections() { + return store.state.x.search?.redirections ?? []; + }, + get relatedTags() { + return store.getters[getGetterPath('relatedTags', 'relatedTags')] ?? []; + }, + get results() { + return store.state.x.search?.results ?? []; + }, + get scroll() { + return store.state.x.scroll?.data ?? {}; + }, + get selectedFilters() { + return store.getters[getGetterPath('facets', 'selectedFilters')] ?? []; + }, + get selectedRelatedTags() { + return store.state.x.relatedTags?.selectedRelatedTags ?? []; + }, + get semanticQueries() { + return store.state.x.semanticQueries?.semanticQueries ?? []; + }, + get spellcheckedQuery() { + return store.state.x.search?.spellcheckedQuery ?? null; + }, + get totalResults() { + return store.state.x.search?.totalResults ?? 0; + }, + get selectedSort() { + return store.state.x.search?.sort ?? ''; + } + }; +} + +/** + * Alias to facilitate retrieving values from the store. + * + * @public + */ +export interface UseAliasAPI { + /** The {@link DeviceXModule} detected device. */ + readonly device: string | null; + /** The {@link FacetsXModule} facets. */ + readonly facets: ReadonlyArray; + /** The {@link HistoryQueriesXModule} history queries matching the query. */ + readonly historyQueries: ReadonlyArray; + /** The {@link HistoryQueriesXModule} history queries with 1 or more results. */ + readonly historyQueriesWithResults: ReadonlyArray; + /** The {@link HistoryQueriesXModule} history queries. */ + readonly fullHistoryQueries: ReadonlyArray; + /** The {@link HistoryQueriesXModule} history queries enabled flag. */ + readonly isHistoryQueriesEnabled: Readonly; + /** The {@link SearchXModule} no results with filters flag. */ + readonly fromNoResultsWithFilters: Readonly; + /** The {@link IdentifierResultsXModule} results. */ + readonly identifierResults: ReadonlyArray; + /** The {@link SearchBoxXModule } input status. */ + readonly searchBoxStatus: InputStatus | undefined; + /** The {@link Empathize} is open state. */ + readonly isEmpathizeOpen: boolean; + /** The {@link NextQueriesXModule} next queries. */ + readonly nextQueries: ReadonlyArray; + /** The {@link SearchXModule} no results situation. */ + readonly noResults: boolean; + /** The {@link SearchXModule} partial results. */ + readonly partialResults: ReadonlyArray; + /** The {@link PopularSearchesXModule} popular searches. */ + readonly popularSearches: ReadonlyArray; + /** The query value of the different modules. */ + readonly query: UseAliasQueryAPI; + /** The {@link QuerySuggestionsXModule} query suggestions that should be displayed. */ + readonly querySuggestions: ReadonlyArray; + /** The {@link QuerySuggestionsXModule} query suggestions. */ + readonly fullQuerySuggestions: ReadonlyArray; + /** The {@link RecommendationsXModule} recommendations. */ + readonly recommendations: ReadonlyArray; + /** The {@link SearchXModule} redirections. */ + readonly redirections: ReadonlyArray; + /** The {@link RelatedTagsXModule} related tags (Both selected and deselected). */ + readonly relatedTags: ReadonlyArray; + /** The {@link SearchXModule} search results. */ + readonly results: ReadonlyArray; + /** The {@link ScrollXModule} data state. */ + readonly scroll: Record; + /** The {@link FacetsXModule} selected filters. */ + readonly selectedFilters: Filter[]; + /** The {@link RelatedTagsXModule} selected related tags. */ + readonly selectedRelatedTags: ReadonlyArray; + /** The {@link SemanticQueriesXModule} queries. */ + readonly semanticQueries: ReadonlyArray; + /** The {@link SearchXModule} spellchecked query. */ + readonly spellcheckedQuery: string | null; + /** The status value of the different modules. */ + readonly status: UseAliasStatusAPI; + /** The {@link SearchXModule} total results. */ + readonly totalResults: number; + /** The {@link SearchXModule} selected sort. */ + readonly selectedSort: string; +} + +/** + * Alias to facilitate retrieving the modules with query. + * + * @public + */ +export interface UseAliasQueryAPI { + /** The {@link FacetsXModule} query. */ + readonly facets: string; + /** The {@link SearchBoxXModule} query. */ + readonly searchBox: string; + /** The {@link NextQueriesXModule} query. */ + readonly nextQueries: string; + /** The {@link QuerySuggestionsXModule} query. */ + readonly querySuggestions: string; + /** The {@link RelatedTagsXModule} query. */ + readonly relatedTags: string; + /** The {@link SearchXModule} query. */ + readonly search: string; +} + +/** + * Alias to facilitate retrieving the modules with status. + * + * @public + */ +export interface UseAliasStatusAPI { + /** The {@link IdentifierResultsXModule} status. */ + readonly identifierResults: RequestStatus | undefined; + /** The {@link NextQueriesXModule} status. */ + readonly nextQueries: RequestStatus | undefined; + /** The {@link PopularSearchesXModule} status. */ + readonly popularSearches: RequestStatus | undefined; + /** The {@link QuerySuggestionsXModule} status. */ + readonly querySuggestions: RequestStatus | undefined; + /** The {@link RecommendationsXModule} status. */ + readonly recommendations: RequestStatus | undefined; + /** The {@link RelatedTagsXModule} status. */ + readonly relatedTags: RequestStatus | undefined; + /** The {@link SearchXModule} status. */ + readonly search: RequestStatus | undefined; +} diff --git a/packages/x-components/src/composables/use-getter.ts b/packages/x-components/src/composables/use-getter.ts index 96632f7d64..b7099f80fc 100644 --- a/packages/x-components/src/composables/use-getter.ts +++ b/packages/x-components/src/composables/use-getter.ts @@ -16,10 +16,10 @@ import { useStore } from './use-store'; export function useGetter< Module extends XModuleName, GetterName extends keyof ExtractGetters & string ->(module: Module, getters: GetterName[]): Dictionary> { +>(module: Module, getters: GetterName[]): Dictionary { const store = useStore(); - return getters.reduce>>((getterDictionary, getterName) => { + return getters.reduce>((getterDictionary, getterName) => { const getterPath = getGetterPath(module, getterName); getterDictionary[getterName] = computed(() => store.getters[getterPath]); return getterDictionary; diff --git a/packages/x-components/src/composables/use-state.ts b/packages/x-components/src/composables/use-state.ts index fce5d2140a..55612eb464 100644 --- a/packages/x-components/src/composables/use-state.ts +++ b/packages/x-components/src/composables/use-state.ts @@ -15,11 +15,11 @@ import { useStore } from './use-store'; export function useState< Module extends XModuleName, Path extends keyof ExtractState & string ->(module: Module, paths: Path[]): Dictionary> { +>(module: Module, paths: Path[]): Dictionary { const store = useStore(); - return paths.reduce>>((stateDictionary, path) => { - stateDictionary[path] = computed(() => store.state.x[module][path]); + return paths.reduce>((stateDictionary, path) => { + stateDictionary[path] = computed(() => store.state.x[module]?.[path]); return stateDictionary; }, {}); }