diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 83074a36a0f..44a8774cbb9 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -5,29 +5,23 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; -import { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; import { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; import { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; import { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; import { InsightStore } from "./components/insight/atomic-insight-interface/store"; import { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; import { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; -import { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; import { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; import { RedirectionPayload } from "./components/common/search-box/redirection-payload"; import { AnyBindings } from "./components/common/interface/bindings"; import { i18n } from "i18next"; import { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; -export { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; -export { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; export { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; export { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; export { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; export { InsightStore } from "./components/insight/atomic-insight-interface/store"; export { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; export { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; -export { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; export { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; export { RedirectionPayload } from "./components/common/search-box/redirection-payload"; export { AnyBindings } from "./components/common/interface/bindings"; @@ -376,21 +370,6 @@ export namespace Components { string[] >; } - interface AtomicInsightResultList { - /** - * The spacing of various elements in the result list, including the gap between results, the gap between parts of a result, and the font sizes of different parts in a result. - */ - "density": ItemDisplayDensity; - /** - * The expected size of the image displayed in the results. - */ - "imageSize": ItemDisplayImageSize; - /** - * Sets a rendering function to bypass the standard HTML template mechanism for rendering results. You can use this function while working with web frameworks that don't use plain HTML syntax such as React, Angular, or Vue. Do not use this method if you integrate Atomic in a plain HTML deployment. - * @param resultRenderingFunction - */ - "setRenderFunction": (resultRenderingFunction: ItemRenderingFunction) => Promise; - } interface AtomicInsightResultQuickviewAction { /** * The `sandbox` attribute to apply to the quickview iframe. The quickview is loaded inside an iframe with a [`sandbox`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox) attribute for security reasons. This attribute exists primarily to protect against potential XSS attacks that could originate from the document being displayed. By default, the sandbox attributes are: `allow-popups allow-top-navigation allow-same-origin`. `allow-same-origin` is not optional, and must always be included in the list of allowed capabilities for the component to function properly. @@ -1331,12 +1310,6 @@ declare global { prototype: HTMLAtomicInsightResultChildrenTemplateElement; new (): HTMLAtomicInsightResultChildrenTemplateElement; }; - interface HTMLAtomicInsightResultListElement extends Components.AtomicInsightResultList, HTMLStencilElement { - } - var HTMLAtomicInsightResultListElement: { - prototype: HTMLAtomicInsightResultListElement; - new (): HTMLAtomicInsightResultListElement; - }; interface HTMLAtomicInsightResultQuickviewActionElement extends Components.AtomicInsightResultQuickviewAction, HTMLStencilElement { } var HTMLAtomicInsightResultQuickviewActionElement: { @@ -1787,7 +1760,6 @@ declare global { "atomic-insight-result-attach-to-case-indicator": HTMLAtomicInsightResultAttachToCaseIndicatorElement; "atomic-insight-result-children": HTMLAtomicInsightResultChildrenElement; "atomic-insight-result-children-template": HTMLAtomicInsightResultChildrenTemplateElement; - "atomic-insight-result-list": HTMLAtomicInsightResultListElement; "atomic-insight-result-quickview-action": HTMLAtomicInsightResultQuickviewActionElement; "atomic-insight-result-template": HTMLAtomicInsightResultTemplateElement; "atomic-insight-search-box": HTMLAtomicInsightSearchBoxElement; @@ -2165,16 +2137,6 @@ declare namespace LocalJSX { string[] >; } - interface AtomicInsightResultList { - /** - * The spacing of various elements in the result list, including the gap between results, the gap between parts of a result, and the font sizes of different parts in a result. - */ - "density"?: ItemDisplayDensity; - /** - * The expected size of the image displayed in the results. - */ - "imageSize"?: ItemDisplayImageSize; - } interface AtomicInsightResultQuickviewAction { /** * The `sandbox` attribute to apply to the quickview iframe. The quickview is loaded inside an iframe with a [`sandbox`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox) attribute for security reasons. This attribute exists primarily to protect against potential XSS attacks that could originate from the document being displayed. By default, the sandbox attributes are: `allow-popups allow-top-navigation allow-same-origin`. `allow-same-origin` is not optional, and must always be included in the list of allowed capabilities for the component to function properly. @@ -2876,7 +2838,6 @@ declare namespace LocalJSX { "atomic-insight-result-attach-to-case-indicator": AtomicInsightResultAttachToCaseIndicator; "atomic-insight-result-children": AtomicInsightResultChildren; "atomic-insight-result-children-template": AtomicInsightResultChildrenTemplate; - "atomic-insight-result-list": AtomicInsightResultList; "atomic-insight-result-quickview-action": AtomicInsightResultQuickviewAction; "atomic-insight-result-template": AtomicInsightResultTemplate; "atomic-insight-search-box": AtomicInsightSearchBox; @@ -2947,7 +2908,6 @@ declare module "@stencil/core" { "atomic-insight-result-attach-to-case-indicator": LocalJSX.AtomicInsightResultAttachToCaseIndicator & JSXBase.HTMLAttributes; "atomic-insight-result-children": LocalJSX.AtomicInsightResultChildren & JSXBase.HTMLAttributes; "atomic-insight-result-children-template": LocalJSX.AtomicInsightResultChildrenTemplate & JSXBase.HTMLAttributes; - "atomic-insight-result-list": LocalJSX.AtomicInsightResultList & JSXBase.HTMLAttributes; "atomic-insight-result-quickview-action": LocalJSX.AtomicInsightResultQuickviewAction & JSXBase.HTMLAttributes; "atomic-insight-result-template": LocalJSX.AtomicInsightResultTemplate & JSXBase.HTMLAttributes; "atomic-insight-search-box": LocalJSX.AtomicInsightSearchBox & JSXBase.HTMLAttributes; diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.spec.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.spec.ts new file mode 100644 index 00000000000..929015bf227 --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.spec.ts @@ -0,0 +1,405 @@ +import { + buildInteractiveResult as buildInsightInteractiveResult, + buildResultList as buildInsightResultList, + buildResultsPerPage as buildInsightResultsPerPage, +} from '@coveo/headless/insight'; +import {html} from 'lit'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import type { + ItemDisplayDensity, + ItemDisplayImageSize, +} from '@/src/components/common/layout/item-layout-utils'; +import {renderInAtomicInsightInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/insight/atomic-insight-interface-fixture'; +import {buildFakeInsightEngine} from '@/vitest-utils/testing-helpers/fixtures/headless/insight/engine'; +import {buildFakeInsightResult} from '@/vitest-utils/testing-helpers/fixtures/headless/insight/result'; +import {buildFakeInsightResultList} from '@/vitest-utils/testing-helpers/fixtures/headless/insight/result-list-controller'; +import {buildFakeInsightResultsPerPage} from '@/vitest-utils/testing-helpers/fixtures/headless/insight/results-per-page-controller'; +import {AtomicInsightResultList} from './atomic-insight-result-list'; +import './atomic-insight-result-list'; +import '@/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template'; + +vi.mock('@/src/components/common/interface/store', {spy: true}); +vi.mock('@/src/components/common/template-controller/template-utils', { + spy: true, +}); +vi.mock('@coveo/headless/insight', {spy: true}); + +describe('atomic-insight-result-list', () => { + const interactiveResult = vi.fn(); + const mockedEngine = buildFakeInsightEngine(); + + beforeEach(() => { + vi.mocked(buildInsightResultList).mockReturnValue( + buildFakeInsightResultList({ + state: { + results: Array.from({length: 1}, (_, i) => + buildFakeInsightResult({uniqueId: i.toString()}) + ), + }, + }) + ); + vi.mocked(buildInsightResultsPerPage).mockReturnValue( + buildFakeInsightResultsPerPage() + ); + + vi.mocked(buildInsightInteractiveResult).mockImplementation( + (_engine, props) => { + return interactiveResult(props); + } + ); + }); + + const mockResultsWithCount = (count: number) => { + vi.mocked(buildInsightResultList).mockReturnValue( + buildFakeInsightResultList({ + state: { + results: Array.from({length: count}, (_, i) => + buildFakeInsightResult({uniqueId: i.toString()}) + ), + }, + }) + ); + }; + + // #initialize ======================================================================================================= + + it('should initialize', async () => { + const element = await setupElement(); + + expect(element).toBeInstanceOf(AtomicInsightResultList); + }); + + // #controller integration ============================================================================================ + + describe('when initialized', () => { + it('should call buildInsightResultList with the engine', async () => { + await setupElement(); + + expect(buildInsightResultList).toHaveBeenCalledWith( + mockedEngine, + expect.objectContaining({ + options: expect.objectContaining({ + fieldsToInclude: undefined, + }), + }) + ); + }); + + it('should call buildInsightResultsPerPage with the engine', async () => { + await setupElement(); + + expect(buildInsightResultsPerPage).toHaveBeenCalledWith(mockedEngine); + }); + }); + + // #rendering ========================================================================================================= + + describe('#render', () => { + describe('when no results', () => { + it('should not render results container', async () => { + vi.mocked(buildInsightResultList).mockReturnValue( + buildFakeInsightResultList({ + state: { + results: [], + hasResults: false, + firstSearchExecuted: true, + }, + }) + ); + + const element = await setupElement(); + const resultList = element.shadowRoot?.querySelector( + '[part="result-list"]' + ); + + expect(resultList).toBeNull(); + }); + }); + + describe('when results exist', () => { + it('should render results container', async () => { + mockResultsWithCount(3); + const element = await setupElement(); + + const resultList = element.shadowRoot?.querySelector( + '[part="result-list"]' + ); + + expect(resultList).toBeDefined(); + }); + + it('should render correct number of atomic-insight-result elements', async () => { + mockResultsWithCount(3); + const element = await setupElement(); + + const results = element.shadowRoot?.querySelectorAll( + 'atomic-insight-result' + ); + + expect(results?.length).toBe(3); + }); + + it('should render results with outline part', async () => { + mockResultsWithCount(2); + const element = await setupElement(); + + const results = element.shadowRoot?.querySelectorAll( + 'atomic-insight-result[part*="outline"]' + ); + + expect(results?.length).toBe(2); + }); + }); + + describe('when app is loading', () => { + it('should render placeholders', async () => { + vi.mocked(buildInsightResultsPerPage).mockReturnValue( + buildFakeInsightResultsPerPage({ + state: {numberOfResults: 5}, + }) + ); + + const element = await setupElement({isAppLoaded: false}); + + const placeholders = element.shadowRoot?.querySelectorAll( + 'atomic-result-placeholder' + ); + + expect(placeholders?.length).toBe(5); + }); + + it('should hide results while showing placeholders', async () => { + mockResultsWithCount(3); + const element = await setupElement({isAppLoaded: false}); + + const resultList = + element.shadowRoot?.querySelector('.list-root.hidden'); + + expect(resultList).toBeDefined(); + }); + }); + }); + + // #atomic-insight-result props ======================================================================================= + + describe('#atomic-insight-result props', () => { + it('should pass correct #density', async () => { + const element = await setupElement({density: 'comfortable'}); + + const atomicResultElement = element.shadowRoot?.querySelector( + 'atomic-insight-result' + ); + + expect(atomicResultElement?.density).toBe('comfortable'); + }); + + it('should pass correct #display', async () => { + const element = await setupElement(); + + const atomicResultElement = element.shadowRoot?.querySelector( + 'atomic-insight-result' + ); + + expect(atomicResultElement?.display).toBe('list'); + }); + + it('should pass correct #imageSize', async () => { + const element = await setupElement({imageSize: 'large'}); + + const atomicResultElement = element.shadowRoot?.querySelector( + 'atomic-insight-result' + ); + + expect(atomicResultElement?.imageSize).toBe('large'); + }); + + it('should pass #interactiveResult', async () => { + const mockResult = buildFakeInsightResult({uniqueId: '123'}); + + vi.mocked(buildInsightResultList).mockReturnValue( + buildFakeInsightResultList({ + state: { + results: [mockResult], + }, + }) + ); + + await setupElement(); + + expect(interactiveResult).toHaveBeenCalledWith({ + options: {result: mockResult}, + }); + }); + + it('should pass correct #loadingFlag', async () => { + const element = await setupElement(); + + const atomicResultElement = element.shadowRoot?.querySelector( + 'atomic-insight-result' + ); + + expect(atomicResultElement?.loadingFlag).toBe( + // biome-ignore lint/suspicious/noExplicitAny: testing private property + (element as any).loadingFlag + ); + }); + + it('should pass correct #result', async () => { + const mockResult1 = buildFakeInsightResult({uniqueId: '123'}); + const mockResult2 = buildFakeInsightResult({uniqueId: '456'}); + + vi.mocked(buildInsightResultList).mockReturnValue( + buildFakeInsightResultList({ + state: { + results: [mockResult1, mockResult2], + }, + }) + ); + + const element = await setupElement(); + + const atomicResultElements = element.shadowRoot?.querySelectorAll( + 'atomic-insight-result' + ); + + expect(atomicResultElements?.[0].result).toBe(mockResult1); + expect(atomicResultElements?.[1].result).toBe(mockResult2); + }); + + it('should pass correct #store', async () => { + const element = await setupElement(); + + const atomicResultElement = element.shadowRoot?.querySelector( + 'atomic-insight-result' + ); + + expect(atomicResultElement?.store).toEqual(element.bindings.store); + }); + + it('should pass template #content', async () => { + const mockResult = buildFakeInsightResult({uniqueId: '123'}); + + vi.mocked(buildInsightResultList).mockReturnValue( + buildFakeInsightResultList({ + state: { + results: [mockResult], + }, + }) + ); + + const element = await setupElement(); + + const mockTemplate = document.createDocumentFragment(); + mockTemplate.appendChild(document.createElement('div')); + + vi.spyOn( + // biome-ignore lint/suspicious/noExplicitAny: mocking private property + (element as any).resultTemplateProvider, + 'getTemplateContent' + ).mockReturnValue(mockTemplate); + + element.requestUpdate(); + await element.updateComplete; + + const atomicResultElement = element.shadowRoot?.querySelector( + 'atomic-insight-result' + ); + + expect(atomicResultElement?.content).toBe(mockTemplate); + }); + }); + + // #setRenderFunction ================================================================================================= + + describe('#setRenderFunction', () => { + it('should set the render function', async () => { + const element = await setupElement(); + const mockRenderFunction = vi.fn(); + + await element.setRenderFunction(mockRenderFunction); + + const atomicResultElement = element.shadowRoot?.querySelector( + 'atomic-insight-result' + ); + + expect(atomicResultElement?.renderingFunction).toBe(mockRenderFunction); + }); + }); + + // #parts ============================================================================================================= + + describe('#parts', () => { + it('should expose result-list part', async () => { + mockResultsWithCount(2); + const element = await setupElement(); + + const parts = getParts(element); + + expect(parts.resultList?.length).toBe(1); + }); + + it('should expose outline part for each result', async () => { + mockResultsWithCount(3); + const element = await setupElement(); + + const parts = getParts(element); + + expect(parts.outline?.length).toBe(3); + }); + }); + + // Helper functions =================================================================================================== + + const setupElement = async ({ + density = 'normal', + imageSize = 'icon', + isAppLoaded = true, + }: { + density?: ItemDisplayDensity; + imageSize?: ItemDisplayImageSize; + isAppLoaded?: boolean; + } = {}) => { + const {element} = + await renderInAtomicInsightInterface({ + template: html` + + + + + + `, + selector: 'atomic-insight-result-list', + bindings: (bindings) => { + bindings.store.state.loadingFlags = isAppLoaded + ? [] + : ['loading-flag']; + bindings.engine = mockedEngine; + bindings.engine.logger = {error: vi.fn()} as never; + return bindings; + }, + }); + + return element; + }; + + const getParts = (element: AtomicInsightResultList) => { + const qs = (part: string, exact = true) => + element.shadowRoot?.querySelectorAll( + `[part${exact ? '' : '*'}="${part}"]` + ); + + return { + outline: qs('outline', false), + resultList: qs('result-list'), + }; + }; +}); diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.ts new file mode 100644 index 00000000000..cc109a27cf3 --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.ts @@ -0,0 +1,319 @@ +import { + buildInteractiveResult as buildInsightInteractiveResult, + buildResultList as buildInsightResultList, + buildResultsPerPage as buildInsightResultsPerPage, + type Result as InsightResult, + type ResultList as InsightResultList, + type ResultListState as InsightResultListState, + type ResultsPerPage as InsightResultsPerPage, + type ResultsPerPageState as InsightResultsPerPageState, +} from '@coveo/headless/insight'; +import {type CSSResultGroup, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {keyed} from 'lit/directives/keyed.js'; +import {map} from 'lit/directives/map.js'; +import {ref} from 'lit/directives/ref.js'; +import {when} from 'lit/directives/when.js'; +import {renderItemPlaceholders} from '@/src/components/common/atomic-result-placeholder/item-placeholders'; +import {createAppLoadedListener} from '@/src/components/common/interface/store'; +import {renderDisplayWrapper} from '@/src/components/common/item-list/display-wrapper'; +import {renderItemList} from '@/src/components/common/item-list/item-list'; +import { + ItemListCommon, + type ItemRenderingFunction, +} from '@/src/components/common/item-list/item-list-common'; +import {ResultTemplateProvider} from '@/src/components/common/item-list/result-template-provider'; +import listDisplayStyles from '@/src/components/common/item-list/styles/list-display.tw.css'; +import placeholderStyles from '@/src/components/common/item-list/styles/placeholders.tw.css'; +import { + getItemListDisplayClasses, + type ItemDisplayDensity, + type ItemDisplayImageSize, + type ItemDisplayLayout, +} from '@/src/components/common/layout/item-layout-utils'; +import type {InsightBindings} from '@/src/components/insight/atomic-insight-interface/atomic-insight-interface'; +import {bindStateToController} from '@/src/decorators/bind-state'; +import {bindingGuard} from '@/src/decorators/binding-guard'; +import {bindings} from '@/src/decorators/bindings'; +import {errorGuard} from '@/src/decorators/error-guard'; +import type {InitializableComponent} from '@/src/decorators/types'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles'; +import {ChildrenUpdateCompleteMixin} from '@/src/mixins/children-update-complete-mixin'; +import {FocusTargetController} from '@/src/utils/accessibility-utils'; +import {randomID} from '@/src/utils/utils'; +import '@/src/components/insight/atomic-insight-result/atomic-insight-result'; +import '@/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template'; +import insightListDisplayStyles from './atomic-insight-result-list.tw.css'; + +/** + * The `atomic-insight-result-list` component is responsible for displaying query results by applying one or more result templates. + * + * @internal + * @slot default - The default slot where the result templates are inserted. + * @part result-list - The element containing every result of a result list + * @part outline - The element displaying an outline or a divider around a result + */ +@customElement('atomic-insight-result-list') +@bindings() +@withTailwindStyles +export class AtomicInsightResultList + extends ChildrenUpdateCompleteMixin(LitElement) + implements InitializableComponent +{ + static styles: CSSResultGroup = [ + placeholderStyles, + listDisplayStyles, + insightListDisplayStyles, + ]; + + public resultList!: InsightResultList; + public resultsPerPage!: InsightResultsPerPage; + + private itemRenderingFunction: ItemRenderingFunction; + private loadingFlag = randomID('firstInsightResultLoaded-'); + private nextNewResultTarget?: FocusTargetController; + private resultListCommon!: ItemListCommon; + private resultTemplateProvider!: ResultTemplateProvider; + private display: ItemDisplayLayout = 'list'; + + @state() + bindings!: InsightBindings; + @state() + error!: Error; + @state() + private isAppLoaded = false; + @state() + private isEveryResultReady = false; + @state() + private resultTemplateRegistered = false; + @state() + private templateHasError = false; + + @bindStateToController('resultList') + @state() + private resultListState!: InsightResultListState; + + @bindStateToController('resultsPerPage') + @state() + private resultsPerPageState!: InsightResultsPerPageState; + + /** + * The spacing of various elements in the result list, including the gap between results, the gap between parts of a result, and the font sizes of different parts in a result. + */ + @property({reflect: true, type: String}) + public density: ItemDisplayDensity = 'normal'; + + /** + * The expected size of the image displayed in the results. + */ + @property({reflect: true, attribute: 'image-size', type: String}) + public imageSize: ItemDisplayImageSize = 'icon'; + + /** + * Sets a rendering function to bypass the standard HTML template mechanism for rendering results. + * You can use this function while working with web frameworks that don't use plain HTML syntax such as React, Angular, or Vue. + * + * Do not use this method if you integrate Atomic in a plain HTML deployment. + * + * @param resultRenderingFunction + */ + public async setRenderFunction( + resultRenderingFunction: ItemRenderingFunction + ) { + this.itemRenderingFunction = resultRenderingFunction; + } + + public initialize() { + this.resultList = buildInsightResultList(this.bindings.engine, { + options: { + fieldsToInclude: this.bindings.store.state.fieldsToInclude || undefined, + }, + }); + this.resultsPerPage = buildInsightResultsPerPage(this.bindings.engine); + + this.initResultTemplateProvider(); + this.initResultListCommon(); + + createAppLoadedListener(this.bindings.store, (isAppLoaded) => { + this.isAppLoaded = isAppLoaded; + }); + } + + public async willUpdate(changedProperties: Map) { + super.willUpdate(changedProperties); + + if (changedProperties.has('resultListState')) { + const oldState = changedProperties.get( + 'resultListState' + ) as InsightResultListState; + if (this.resultListState.firstSearchExecuted) { + this.bindings.store.unsetLoadingFlag(this.loadingFlag); + } + if (!oldState?.isLoading && this.resultListState.isLoading) { + this.isEveryResultReady = false; + } + } + + await this.updateResultReadyState(); + } + + private async updateResultReadyState() { + if ( + this.isAppLoaded && + !this.isEveryResultReady && + this.resultListState?.firstSearchExecuted && + this.resultListState?.results?.length > 0 + ) { + await this.getUpdateComplete(); + this.isEveryResultReady = true; + } + } + + @bindingGuard() + @errorGuard() + render() { + return html`${renderItemList({ + props: { + hasError: this.resultListState.hasError, + hasItems: this.resultListState.hasResults, + hasTemplate: this.resultTemplateRegistered, + firstRequestExecuted: this.resultListState.firstSearchExecuted, + templateHasError: this.templateHasError, + }, + })( + html`${when( + this.templateHasError, + () => html``, + () => { + const listClasses = this.computeListDisplayClasses(); + const resultClasses = `${listClasses} ${!this.isEveryResultReady && 'hidden'}`; + + return html` + ${when(this.isAppLoaded, () => + renderDisplayWrapper({ + props: { + listClasses: resultClasses, + display: this.display, + }, + })(this.renderList()) + )} + ${when(!this.isEveryResultReady, () => + renderDisplayWrapper({ + props: {listClasses, display: this.display}, + })( + renderItemPlaceholders({ + props: { + density: this.density, + display: this.display, + imageSize: this.imageSize, + numberOfPlaceholders: + this.resultsPerPageState.numberOfResults || 10, + }, + }) + ) + )} + `; + } + )}` + )}`; + } + + private initResultTemplateProvider() { + this.resultTemplateProvider = new ResultTemplateProvider({ + includeDefaultTemplate: true, + templateElements: Array.from( + this.querySelectorAll('atomic-insight-result-template') + ), + getResultTemplateRegistered: () => this.resultTemplateRegistered, + getTemplateHasError: () => this.templateHasError, + setResultTemplateRegistered: (value: boolean) => { + this.resultTemplateRegistered = value; + }, + setTemplateHasError: (value: boolean) => { + this.templateHasError = value; + }, + bindings: this.bindings, + }); + } + + private initResultListCommon() { + this.resultListCommon = new ItemListCommon({ + engineSubscribe: this.bindings.engine.subscribe, + getCurrentNumberOfItems: () => this.resultListState.results.length, + getIsLoading: () => this.resultListState.isLoading, + host: this, + loadingFlag: this.loadingFlag, + nextNewItemTarget: this.focusTarget, + store: this.bindings.store, + }); + } + + private computeListDisplayClasses() { + const displayPlaceholders = !(this.isAppLoaded && this.isEveryResultReady); + + return getItemListDisplayClasses( + this.display, + this.density, + this.imageSize, + this.resultListState?.isLoading, + displayPlaceholders + ); + } + + private renderList() { + return html`${map(this.resultListState.results, (result, index) => { + return html`${keyed( + this.getResultId(result), + html` + element instanceof HTMLElement && + this.resultListCommon.setNewResultRef(element, index) + )} + .content=${this.getContent(result)} + .density=${this.density} + .display=${this.display} + .imageSize=${this.imageSize} + .interactiveResult=${this.getInteractiveResult(result)} + .loadingFlag=${this.loadingFlag} + .result=${result} + .renderingFunction=${this.itemRenderingFunction} + .store=${this.bindings.store as never} + >` + )}`; + })}`; + } + + private get focusTarget() { + if (!this.nextNewResultTarget) { + this.nextNewResultTarget = new FocusTargetController(this, this.bindings); + } + return this.nextNewResultTarget; + } + + private getContent(result: InsightResult) { + return this.resultTemplateProvider.getTemplateContent(result); + } + + private getInteractiveResult(result: InsightResult) { + return buildInsightInteractiveResult(this.bindings.engine, { + options: {result}, + }); + } + + private getResultId(result: InsightResult) { + return this.resultListCommon.getResultId( + result.uniqueId, + this.resultListState.searchResponseId, + this.density, + this.imageSize + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-insight-result-list': AtomicInsightResultList; + } +} diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.tw.css.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.tw.css.ts new file mode 100644 index 00000000000..d2018b5e240 --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-result-list/atomic-insight-result-list.tw.css.ts @@ -0,0 +1,42 @@ +import {css} from 'lit'; + +const styles = css` +@reference './mixins.pcss'; +@reference '../../../../global/global.pcss'; + +[part~='divider'] { + &:not(:last-child) { + @apply border-b-neutral border-b; + padding-bottom: 1rem; + } + margin-bottom: 1rem; +} + +.list-root { + &.display-list { + display: flex; + flex-direction: column; + + .result-component, + atomic-result-placeholder { + width: auto; + } + + @apply atomic-list-with-dividers; + + .result-component[part~='outline']::before { + @apply mx-6 my-0; + } + } + + &.placeholder { + padding: 0.5rem 1.5rem; + } +} + +atomic-result:not(.hydrated) { + visibility: hidden; +} +`; + +export default styles; diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 85485a08de3..683ed5bf8c8 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -59,6 +59,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-icon', 'atomic-insight-generate-answer-button', 'atomic-insight-interface', + 'atomic-insight-result-list', 'atomic-insight-tab', 'atomic-insight-tabs', 'atomic-ipx-tab', diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/result-list-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/result-list-controller.ts new file mode 100644 index 00000000000..d74f8521742 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/result-list-controller.ts @@ -0,0 +1,35 @@ +import type { + ResultList as InsightResultList, + ResultListState as InsightResultListState, +} from '@coveo/headless/insight'; +import {vi} from 'vitest'; +import {genericSubscribe} from '../common.js'; + +export const defaultState = { + firstSearchExecuted: true, + hasError: false, + hasResults: true, + isLoading: false, + results: [], + searchResponseId: 'test-search-response-id', + moreResultsAvailable: false, +} satisfies InsightResultListState; + +export const defaultImplementation = { + subscribe: genericSubscribe, + state: defaultState, + fetchMoreResults: vi.fn(), +} satisfies InsightResultList; + +export const buildFakeInsightResultList = ({ + implementation, + state, +}: Partial<{ + implementation?: Partial; + state?: Partial; +}>): InsightResultList => + ({ + ...defaultImplementation, + ...implementation, + ...{state: {...defaultState, ...(state || {})}}, + }) as InsightResultList; diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/result.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/result.ts new file mode 100644 index 00000000000..3ab6aabd059 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/result.ts @@ -0,0 +1,42 @@ +import type {Result as InsightResult} from '@coveo/headless/insight'; + +type DeepPartialResult = Partial> & { + raw?: Record; + [key: string]: unknown; +}; + +export const buildFakeInsightResult = ( + result?: DeepPartialResult +): InsightResult => { + const {raw, ...restResult} = result ?? {}; + return { + title: 'title', + uri: 'https://example.com/uri', + printableUri: 'https://example.com/printableUri', + clickUri: '', + uniqueId: 'uniqueId', + excerpt: 'excerpt', + firstSentences: 'firstSentences', + summary: null, + flags: '', + hasHtmlVersion: false, + score: 0, + percentScore: 0, + rankingInfo: null, + isTopResult: false, + isRecommendation: false, + titleHighlights: [], + firstSentencesHighlights: [], + excerptHighlights: [], + printableUriHighlights: [], + summaryHighlights: [], + absentTerms: [], + isUserActionView: false, + searchUid: 'searchUid', + ...restResult, + raw: { + urihash: 'urihash', + ...raw, + }, + } satisfies InsightResult; +}; diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/results-per-page-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/results-per-page-controller.ts new file mode 100644 index 00000000000..d891e60eeca --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/results-per-page-controller.ts @@ -0,0 +1,20 @@ +import type {ResultsPerPage as InsightResultsPerPage} from '@coveo/headless/insight'; +import {vi} from 'vitest'; +import {genericSubscribe} from '../common.js'; + +export const buildFakeInsightResultsPerPage = ( + options: Partial = {} +): InsightResultsPerPage => { + const defaultState = { + numberOfResults: 10, + ...options, + }; + + return { + state: defaultState, + onChange: vi.fn(), + subscribe: genericSubscribe, + set: vi.fn(), + isSetTo: vi.fn().mockReturnValue(true), + } as InsightResultsPerPage; +};