diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 83074a36a0f..c86b360e933 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -7,12 +7,12 @@ 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 { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; 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 { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; 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"; @@ -21,12 +21,12 @@ 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 { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; 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 { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; 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"; @@ -131,35 +131,6 @@ export namespace Components { */ "sortCriteria": InsightFacetSortCriterion; } - interface AtomicInsightFoldedResultList { - /** - * The name of the field that uniquely identifies a result within a collection. - * @defaultValue `foldingchild` - */ - "childField"?: string; - /** - * The name of the field on which to do the folding. The folded result list component will use the values of this field to resolve the collections of result items. - * @defaultValue `foldingcollection` - */ - "collectionField"?: string; - /** - * 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; - /** - * The name of the field that determines whether a certain result is a top result containing other child results within a collection. - * @defaultValue `foldingparent` - */ - "parentField"?: string; - /** - * 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. - */ - "setRenderFunction": (resultRenderingFunction: ItemRenderingFunction) => Promise; - } interface AtomicInsightFullSearchButton { "tooltip": string; } @@ -1183,12 +1154,6 @@ declare global { prototype: HTMLAtomicInsightFacetElement; new (): HTMLAtomicInsightFacetElement; }; - interface HTMLAtomicInsightFoldedResultListElement extends Components.AtomicInsightFoldedResultList, HTMLStencilElement { - } - var HTMLAtomicInsightFoldedResultListElement: { - prototype: HTMLAtomicInsightFoldedResultListElement; - new (): HTMLAtomicInsightFoldedResultListElement; - }; interface HTMLAtomicInsightFullSearchButtonElement extends Components.AtomicInsightFullSearchButton, HTMLStencilElement { } var HTMLAtomicInsightFullSearchButtonElement: { @@ -1768,7 +1733,6 @@ declare global { "atomic-generated-answer-feedback-modal": HTMLAtomicGeneratedAnswerFeedbackModalElement; "atomic-insight-edit-toggle": HTMLAtomicInsightEditToggleElement; "atomic-insight-facet": HTMLAtomicInsightFacetElement; - "atomic-insight-folded-result-list": HTMLAtomicInsightFoldedResultListElement; "atomic-insight-full-search-button": HTMLAtomicInsightFullSearchButtonElement; "atomic-insight-generated-answer": HTMLAtomicInsightGeneratedAnswerElement; "atomic-insight-history-toggle": HTMLAtomicInsightHistoryToggleElement; @@ -1924,31 +1888,6 @@ declare namespace LocalJSX { */ "sortCriteria"?: InsightFacetSortCriterion; } - interface AtomicInsightFoldedResultList { - /** - * The name of the field that uniquely identifies a result within a collection. - * @defaultValue `foldingchild` - */ - "childField"?: string; - /** - * The name of the field on which to do the folding. The folded result list component will use the values of this field to resolve the collections of result items. - * @defaultValue `foldingcollection` - */ - "collectionField"?: string; - /** - * 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; - /** - * The name of the field that determines whether a certain result is a top result containing other child results within a collection. - * @defaultValue `foldingparent` - */ - "parentField"?: string; - } interface AtomicInsightFullSearchButton { "tooltip"?: string; } @@ -2857,7 +2796,6 @@ declare namespace LocalJSX { "atomic-generated-answer-feedback-modal": AtomicGeneratedAnswerFeedbackModal; "atomic-insight-edit-toggle": AtomicInsightEditToggle; "atomic-insight-facet": AtomicInsightFacet; - "atomic-insight-folded-result-list": AtomicInsightFoldedResultList; "atomic-insight-full-search-button": AtomicInsightFullSearchButton; "atomic-insight-generated-answer": AtomicInsightGeneratedAnswer; "atomic-insight-history-toggle": AtomicInsightHistoryToggle; @@ -2928,7 +2866,6 @@ declare module "@stencil/core" { "atomic-generated-answer-feedback-modal": LocalJSX.AtomicGeneratedAnswerFeedbackModal & JSXBase.HTMLAttributes; "atomic-insight-edit-toggle": LocalJSX.AtomicInsightEditToggle & JSXBase.HTMLAttributes; "atomic-insight-facet": LocalJSX.AtomicInsightFacet & JSXBase.HTMLAttributes; - "atomic-insight-folded-result-list": LocalJSX.AtomicInsightFoldedResultList & JSXBase.HTMLAttributes; "atomic-insight-full-search-button": LocalJSX.AtomicInsightFullSearchButton & JSXBase.HTMLAttributes; "atomic-insight-generated-answer": LocalJSX.AtomicInsightGeneratedAnswer & JSXBase.HTMLAttributes; "atomic-insight-history-toggle": LocalJSX.AtomicInsightHistoryToggle & JSXBase.HTMLAttributes; diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.mdx b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.mdx new file mode 100644 index 00000000000..8c0cce5122a --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.mdx @@ -0,0 +1,58 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import * as AtomicInsightFoldedResultListStories from './atomic-insight-folded-result-list.new.stories'; +import { AtomicDocTemplate } from '@/storybook-utils/documentation/atomic-doc-template'; + + + + + +This component is typically placed within an Insight interface to display folded query results. Folded results group related documents together, such as case threads or support article versions. + +```html + + ... + + ... + + + +``` + +## Defining Result Templates + +You can define custom templates for how results and their children are displayed: + +```html + + + + + +``` + +For more information on Result Folding, see [Result Folding](https://docs.coveo.com/en/1884). + + diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.new.stories.tsx b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.new.stories.tsx new file mode 100644 index 00000000000..da17742293e --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.new.stories.tsx @@ -0,0 +1,204 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: <> */ + +import type {Meta, StoryObj as Story} from '@storybook/web-components-vite'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; +import {MockInsightApi} from '@/storybook-utils/api/insight/mock'; +import {baseFoldedResponse} from '@/storybook-utils/api/insight/search-response'; +import {parameters} from '@/storybook-utils/common/common-meta-parameters'; +import {wrapInInsightInterface} from '@/storybook-utils/insight/insight-interface-wrapper'; + +const SLOTS_DEFAULT = ` + + + +`; + +const mockInsightApi = new MockInsightApi(); + +const {decorator, play} = wrapInInsightInterface(); +const {events, args, argTypes, template} = getStorybookHelpers( + 'atomic-insight-folded-result-list', + {excludeCategories: ['methods']} +); + +const meta: Meta = { + component: 'atomic-insight-folded-result-list', + title: 'Insight/Folded Result List', + id: 'atomic-insight-folded-result-list', + render: (args) => template(args), + decorators: [decorator], + parameters: { + ...parameters, + actions: { + handles: events, + }, + msw: {handlers: [...mockInsightApi.handlers]}, + }, + args, + argTypes, + beforeEach: async () => { + mockInsightApi.searchEndpoint.clear(); + }, + play, + tags: ['!dev'], +}; + +export default meta; + +export const Default: Story = { + args: { + 'default-slot': SLOTS_DEFAULT, + }, + beforeEach: async () => { + mockInsightApi.searchEndpoint.mockOnce(() => baseFoldedResponse); + mockInsightApi.searchEndpoint.mockOnce(() => { + const results = baseFoldedResponse.results; + results[0]!.childResults.push({ + title: 'Security Best Practices', + excerpt: 'Essential security guidelines', + clickUri: 'https://support.example.com/kb/security', + uniqueId: 'kb-security-child', + raw: { + foldingcollection: 'Knowledge Base', + foldingchild: ['security'], + foldingparent: 'kb', + }, + }); + results[0].totalNumberOfChildResults = 3; + return { + ...baseFoldedResponse, + results, + }; + }); + }, + play, +}; + +export const WithNoResultChildren: Story = { + name: 'With no result children', + args: { + 'default-slot': SLOTS_DEFAULT, + }, + beforeEach: async () => { + mockInsightApi.searchEndpoint.mockOnce(() => ({ + ...baseFoldedResponse, + results: [ + { + ...baseFoldedResponse.results[0], + parentResult: null, + totalNumberOfChildResults: 0, + childResults: [], + }, + ] as unknown as typeof baseFoldedResponse.results, + })); + }, + play, +}; + +export const WithFewResultChildren: Story = { + name: 'With result children', + args: { + 'default-slot': SLOTS_DEFAULT, + }, + beforeEach: async () => { + mockInsightApi.searchEndpoint.mockOnce(() => ({ + ...baseFoldedResponse, + results: [ + { + ...baseFoldedResponse.results[0]!, + totalNumberOfChildResults: 2, + }, + ...baseFoldedResponse.results.slice(1), + ], + })); + }, + play, +}; + +export const WithMoreResultsAvailableAndNoChildren: Story = { + name: 'With more results available and no children', + args: { + 'default-slot': SLOTS_DEFAULT, + }, + beforeEach: async () => { + mockInsightApi.searchEndpoint.mockOnce(() => ({ + ...baseFoldedResponse, + results: [ + { + ...baseFoldedResponse.results[0]!, + totalNumberOfChildResults: 10, + childResults: [], + }, + ], + })); + }, + play, +}; diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.spec.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.spec.ts new file mode 100644 index 00000000000..d00c1bf12a0 --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.spec.ts @@ -0,0 +1,375 @@ +import { + buildFoldedResultList, + buildInteractiveResult, + buildResultsPerPage, +} from '@coveo/headless/insight'; +import {html} from 'lit'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {page} from 'vitest/browser'; +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 {genericSubscribe} from '@/vitest-utils/testing-helpers/fixtures/headless/common'; +import {buildFakeInsightEngine} from '@/vitest-utils/testing-helpers/fixtures/headless/insight/engine'; +import { + buildFakeFoldedCollection, + buildFakeFoldedResultList, +} from '@/vitest-utils/testing-helpers/fixtures/headless/search/folded-result-list-controller'; +import {buildFakeResult} from '@/vitest-utils/testing-helpers/fixtures/headless/search/result'; +import {buildFakeResultsPerPage} from '@/vitest-utils/testing-helpers/fixtures/headless/search/results-per-page-controller'; +import {AtomicInsightFoldedResultList} from './atomic-insight-folded-result-list'; +import './atomic-insight-folded-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-folded-result-list', () => { + const interactiveResult = vi.fn(); + const mockedEngine = buildFakeInsightEngine(); + + beforeEach(() => { + vi.mocked(buildFoldedResultList).mockReturnValue( + buildFakeFoldedResultList({ + state: { + results: [buildFakeFoldedCollection()], + }, + }) + ); + vi.mocked(buildResultsPerPage).mockReturnValue(buildFakeResultsPerPage()); + + vi.mocked(buildInteractiveResult).mockImplementation((_engine, props) => { + return interactiveResult(props); + }); + }); + + interface RenderOptions { + density?: ItemDisplayDensity; + imageSize?: ItemDisplayImageSize; + collectionField?: string; + parentField?: string; + childField?: string; + numberOfFoldedResults?: number; + isAppLoaded?: boolean; + } + + const renderFoldedResultList = async ({ + density, + imageSize, + collectionField, + parentField, + childField, + numberOfFoldedResults, + isAppLoaded = true, + }: RenderOptions = {}) => { + const {element} = + await renderInAtomicInsightInterface({ + template: html` + + + + `, + selector: 'atomic-insight-folded-result-list', + bindings: (bindings) => { + bindings.store.state.loadingFlags = isAppLoaded + ? [] + : ['loading-flag']; + bindings.engine = mockedEngine; + return bindings; + }, + }); + + return { + element, + parts: { + resultList: element.shadowRoot?.querySelector('[part="result-list"]'), + outline: element.shadowRoot?.querySelector('[part="outline"]'), + }, + }; + }; + + const mockFoldedCollections = (count: number) => { + vi.mocked(buildFoldedResultList).mockReturnValue( + buildFakeFoldedResultList({ + state: { + results: Array.from({length: count}, (_, i) => + buildFakeFoldedCollection({ + result: buildFakeResult({uniqueId: i.toString()}), + }) + ), + }, + }) + ); + }; + + it('should initialize', async () => { + const {element} = await renderFoldedResultList(); + + expect(element).toBeInstanceOf(AtomicInsightFoldedResultList); + }); + + describe('#initialize', () => { + it('should call buildFoldedResultList with engine and default options', async () => { + await renderFoldedResultList(); + + expect(vi.mocked(buildFoldedResultList)).toHaveBeenCalledWith( + mockedEngine, + { + options: { + folding: { + collectionField: undefined, + parentField: undefined, + childField: undefined, + numberOfFoldedResults: 2, + }, + }, + } + ); + }); + + it('should call buildFoldedResultList with custom folding options', async () => { + await renderFoldedResultList({ + collectionField: 'custom-collection', + parentField: 'custom-parent', + childField: 'custom-child', + numberOfFoldedResults: 5, + }); + + expect(vi.mocked(buildFoldedResultList)).toHaveBeenCalledWith( + mockedEngine, + { + options: { + folding: { + collectionField: 'custom-collection', + parentField: 'custom-parent', + childField: 'custom-child', + numberOfFoldedResults: 5, + }, + }, + } + ); + }); + + it('should call buildResultsPerPage with engine', async () => { + await renderFoldedResultList(); + + expect(vi.mocked(buildResultsPerPage)).toHaveBeenCalledWith(mockedEngine); + }); + }); + + describe('#render when app is loaded', () => { + it('should render result-list part', async () => { + mockFoldedCollections(3); + const {parts} = await renderFoldedResultList(); + + expect(parts.resultList).toBeTruthy(); + await expect + .element(page.elementLocator(parts.resultList!)) + .toBeInTheDocument(); + }); + + it('should render atomic-insight-result for each folded collection', async () => { + mockFoldedCollections(3); + await renderFoldedResultList(); + + const results = page.getByRole('listitem'); + await expect.element(results.first()).toBeInTheDocument(); + expect(await results.all()).toHaveLength(3); + }); + + it('should pass correct density to atomic-insight-result', async () => { + mockFoldedCollections(1); + await renderFoldedResultList({density: 'compact'}); + + const result = page.getByRole('listitem').first(); + expect(await result.getAttribute('density')).toBe('compact'); + }); + + it('should pass correct imageSize to atomic-insight-result', async () => { + mockFoldedCollections(1); + await renderFoldedResultList({imageSize: 'large'}); + + const result = page.getByRole('listitem').first(); + expect(await result.getAttribute('imagesize')).toBe('large'); + }); + }); + + describe('#render when app is not loaded', () => { + it('should render placeholders when app is not loaded', async () => { + mockFoldedCollections(3); + await renderFoldedResultList({isAppLoaded: false}); + + const placeholders = page + .getByRole('status', {name: 'Loading'}) + .elements(); + expect(await placeholders).toHaveLength(10); + }); + }); + + describe('when folded result list has no results', () => { + beforeEach(() => { + vi.mocked(buildFoldedResultList).mockReturnValue( + buildFakeFoldedResultList({ + state: { + results: [], + hasResults: false, + }, + }) + ); + }); + + it('should not render result-list part', async () => { + const {parts} = await renderFoldedResultList(); + + expect(parts.resultList).toBeFalsy(); + }); + }); + + describe('when folded result list has an error', () => { + beforeEach(() => { + vi.mocked(buildFoldedResultList).mockReturnValue( + buildFakeFoldedResultList({ + state: { + hasError: true, + }, + }) + ); + }); + + it('should render query error', async () => { + await renderFoldedResultList(); + + const errorContainer = page.getByRole('alert'); + await expect.element(errorContainer).toBeInTheDocument(); + }); + }); + + describe('#disconnectedCallback', () => { + it('should remove event listeners when component is disconnected', async () => { + const {element} = await renderFoldedResultList(); + + const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener'); + + element.disconnectedCallback(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'atomic/resolveFoldedResultList', + expect.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'atomic/loadCollection', + expect.any(Function) + ); + }); + }); + + describe('#setRenderFunction', () => { + it('should accept a custom render function', async () => { + const {element} = await renderFoldedResultList(); + const customRenderFunction = vi.fn(); + + await element.setRenderFunction(customRenderFunction); + + expect(element.itemRenderingFunction).toBe(customRenderFunction); + }); + }); + + describe('event handlers', () => { + it('should handle atomic/resolveFoldedResultList event', async () => { + const {element} = await renderFoldedResultList(); + + const event = new CustomEvent('atomic/resolveFoldedResultList', { + detail: vi.fn(), + bubbles: true, + cancelable: true, + }); + + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + const stopPropagationSpy = vi.spyOn(event, 'stopPropagation'); + + element.dispatchEvent(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(event.detail).toHaveBeenCalledWith(element.foldedResultList); + }); + + it('should handle atomic/loadCollection event', async () => { + const {element} = await renderFoldedResultList(); + const mockCollection = buildFakeFoldedCollection(); + + const loadCollectionSpy = vi.fn(); + element.foldedResultList = { + ...element.foldedResultList, + loadCollection: loadCollectionSpy, + subscribe: genericSubscribe, + }; + + const event = new CustomEvent('atomic/loadCollection', { + detail: mockCollection, + bubbles: true, + cancelable: true, + }); + + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + const stopPropagationSpy = vi.spyOn(event, 'stopPropagation'); + + element.dispatchEvent(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + expect(loadCollectionSpy).toHaveBeenCalledWith(mockCollection); + }); + }); + + describe('prop validation', () => { + it('should accept valid density values', async () => { + const densities: ItemDisplayDensity[] = [ + 'normal', + 'comfortable', + 'compact', + ]; + + for (const density of densities) { + const {element} = await renderFoldedResultList({density}); + expect(element.density).toBe(density); + } + }); + + it('should accept valid imageSize values', async () => { + const imageSizes: ItemDisplayImageSize[] = [ + 'small', + 'large', + 'icon', + 'none', + ]; + + for (const imageSize of imageSizes) { + const {element} = await renderFoldedResultList({imageSize}); + expect(element.imageSize).toBe(imageSize); + } + }); + + it('should accept numberOfFoldedResults of 0 or greater', async () => { + const {element} = await renderFoldedResultList({ + numberOfFoldedResults: 0, + }); + expect(element.numberOfFoldedResults).toBe(0); + }); + }); +}); diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.ts new file mode 100644 index 00000000000..26c5a4ebc18 --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/atomic-insight-folded-result-list.ts @@ -0,0 +1,432 @@ +import {NumberValue, Schema, StringValue} from '@coveo/bueno'; +import { + buildFoldedResultList as buildInsightFoldedResultList, + buildInteractiveResult as buildInsightInteractiveResult, + buildResultsPerPage as buildInsightResultsPerPage, + type FoldedCollection as InsightFoldedCollection, + type FoldedResultList as InsightFoldedResultList, + type FoldedResultListState as InsightFoldedResultListState, + type ResultListProps as InsightResultListProps, + 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 {extractUnfoldedItem} from '@/src/components/common/item-list/unfolded-item'; +import { + getItemListDisplayClasses, + type ItemDisplayDensity, + type ItemDisplayImageSize, + type ItemDisplayLayout, +} from '@/src/components/common/layout/item-layout-utils'; +import {ValidatePropsController} from '@/src/components/common/validate-props-controller/validate-props-controller'; +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/atomic-insight-result-template/atomic-insight-result-template'; +import type {FoldedItemListContextEvent} from '@/src/components/common/item-list/context/folded-item-list-context-controller'; + +/** + * The `atomic-insight-folded-result-list` component is responsible for displaying folded query results in an Insight interface, by applying one or more result templates for up to three layers (that is, to the result, child, and grandchild). + * + * @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-folded-result-list') +@bindings() +@withTailwindStyles +export class AtomicInsightFoldedResultList + extends ChildrenUpdateCompleteMixin(LitElement) + implements InitializableComponent +{ + static styles: CSSResultGroup = [placeholderStyles, listDisplayStyles]; + + public foldedResultList!: InsightFoldedResultList; + public resultsPerPage!: InsightResultsPerPage; + + private itemRenderingFunction: ItemRenderingFunction; + private loadingFlag = randomID('firstResultLoaded-'); + private nextNewResultTarget?: FocusTargetController; + private resultListCommon!: ItemListCommon; + private resultTemplateProvider!: ResultTemplateProvider; + private readonly display: ItemDisplayLayout = 'list'; + + /** + * 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'; + + /** + * The name of the field on which to do the folding. The folded result list component will use the values of this field to resolve the collections of result items. + * + * @defaultValue `foldingcollection` + */ + @property({reflect: true, attribute: 'collection-field', type: String}) + public collectionField?: string; + + /** + * The name of the field that determines whether a certain result is a top result containing other child results within a collection. + * + * @defaultValue `foldingparent` + */ + @property({reflect: true, attribute: 'parent-field', type: String}) + public parentField?: string; + + /** + * The name of the field that uniquely identifies a result within a collection. + * + * @defaultValue `foldingchild` + */ + @property({reflect: true, attribute: 'child-field', type: String}) + public childField?: string; + + /** + * The initial number of child results to request for each folded collection, before expansion. + * + * @defaultValue `2` + * + * @example For an email thread with a total of 20 messages, using the default value of `2` will request the top two child messages, based on the current sort criteria and query, to be returned as children of the parent message. + * The user can then click to expand the collection and see the remaining messages that match the current query (that is, not necessarily all remaining 18 messages). Those messages will be sorted based on the current sort criteria (that is, not necessarily by date). + * For more info on Result Folding, see [Result Folding](https://docs.coveo.com/en/1884). + */ + @property({ + reflect: true, + attribute: 'number-of-folded-results', + type: Number, + }) + public numberOfFoldedResults = 2; + + @state() + bindings!: InsightBindings; + @state() + error!: Error; + @state() + private isAppLoaded = false; + @state() + private isEveryResultReady = false; + @state() + private resultTemplateRegistered = false; + @state() + private templateHasError = false; + + @bindStateToController('foldedResultList') + @state() + private foldedResultListState!: InsightFoldedResultListState; + + @bindStateToController('resultsPerPage') + @state() + private resultsPerPageState!: InsightResultsPerPageState; + + constructor() { + super(); + + new ValidatePropsController( + this, + () => ({ + density: this.density, + imageSize: this.imageSize, + numberOfFoldedResults: this.numberOfFoldedResults, + }), + new Schema({ + density: new StringValue({ + constrainTo: ['normal', 'comfortable', 'compact'], + }), + imageSize: new StringValue({ + constrainTo: ['small', 'large', 'icon', 'none'], + }), + numberOfFoldedResults: new NumberValue({min: 0}), + }) + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener( + 'atomic/resolveFoldedResultList', + this.handleResolveFoldedResultList as EventListener + ); + this.removeEventListener( + 'atomic/loadCollection', + this.handleLoadCollection as EventListener + ); + } + + public initialize() { + this.foldedResultList = this.initFolding(); + this.resultsPerPage = buildInsightResultsPerPage(this.bindings.engine); + + this.initResultTemplateProvider(); + this.initResultListCommon(); + + createAppLoadedListener(this.bindings.store, (isAppLoaded) => { + this.isAppLoaded = isAppLoaded; + }); + + this.addEventListener( + 'atomic/resolveFoldedResultList', + this.handleResolveFoldedResultList as EventListener + ); + this.addEventListener( + 'atomic/loadCollection', + this.handleLoadCollection as EventListener + ); + } + + /** + * 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. + */ + public async setRenderFunction( + resultRenderingFunction: ItemRenderingFunction + ) { + this.itemRenderingFunction = resultRenderingFunction; + } + + willUpdate(changedProperties: Map): void { + if (changedProperties.has('foldedResultListState')) { + const oldState = changedProperties.get( + 'foldedResultListState' + ) as InsightFoldedResultListState; + if (this.foldedResultListState.firstSearchExecuted) { + this.bindings.store.unsetLoadingFlag(this.loadingFlag); + } + if (!oldState?.isLoading && this.foldedResultListState.isLoading) { + this.isEveryResultReady = false; + } + } + + this.updateResultReadyState(); + } + + private async updateResultReadyState() { + if ( + this.isAppLoaded && + !this.isEveryResultReady && + this.foldedResultListState?.firstSearchExecuted && + this.foldedResultListState?.results?.length > 0 + ) { + await this.getUpdateComplete(); + this.isEveryResultReady = true; + } + } + + @bindingGuard() + @errorGuard() + render() { + return html`${renderItemList({ + props: { + hasError: this.foldedResultListState.hasError, + hasItems: this.foldedResultListState.hasResults, + hasTemplate: this.resultTemplateRegistered, + firstRequestExecuted: this.foldedResultListState.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, + }, + })(html`${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 handleResolveFoldedResultList = ( + event: FoldedItemListContextEvent + ) => { + event.preventDefault(); + event.stopPropagation(); + event.detail(this.foldedResultList); + }; + + private handleLoadCollection = ( + event: CustomEvent + ) => { + event.preventDefault(); + event.stopPropagation(); + this.foldedResultList.loadCollection(event.detail); + }; + + private initFolding( + props: InsightResultListProps = {options: {}} + ): InsightFoldedResultList { + return buildInsightFoldedResultList(this.bindings.engine, { + options: { + ...props.options, + folding: { + collectionField: this.collectionField, + parentField: this.parentField, + childField: this.childField, + numberOfFoldedResults: this.numberOfFoldedResults, + }, + }, + }); + } + + 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.foldedResultListState.results.length, + getIsLoading: () => this.foldedResultListState.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.foldedResultListState?.isLoading, + displayPlaceholders + ); + } + + private renderList() { + return html`${map( + this.foldedResultListState.results, + (collection, index) => { + return html`${keyed( + this.getResultId(collection), + html` + element instanceof HTMLElement && + this.resultListCommon.setNewResultRef(element, index) + )} + .content=${this.getContent(collection)} + .density=${this.density} + .display=${this.display} + .imageSize=${this.imageSize} + .interactiveResult=${this.getInteractiveResult(collection)} + .loadingFlag=${this.loadingFlag} + .result=${collection} + .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(collection: InsightFoldedCollection) { + const result = extractUnfoldedItem(collection); + return this.resultTemplateProvider.getTemplateContent(result); + } + + private getInteractiveResult(collection: InsightFoldedCollection) { + const result = extractUnfoldedItem(collection); + return buildInsightInteractiveResult(this.bindings.engine, { + options: {result}, + }); + } + + private getResultId(collection: InsightFoldedCollection) { + const result = extractUnfoldedItem(collection); + return this.resultListCommon.getResultId( + result.uniqueId, + this.foldedResultListState.searchResponseId, + this.density, + this.imageSize + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-insight-folded-result-list': AtomicInsightFoldedResultList; + } +} diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/atomic-insight-folded-result-list.e2e.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/atomic-insight-folded-result-list.e2e.ts new file mode 100644 index 00000000000..653e4835ca4 --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/atomic-insight-folded-result-list.e2e.ts @@ -0,0 +1,130 @@ +import {expect, test} from './fixture'; + +test.describe('atomic-insight-folded-result-list', () => { + test.describe('when more results are NOT available & there are NO result children', () => { + test.beforeEach(async ({insightFoldedResultList}) => { + await insightFoldedResultList.load({story: 'with-no-result-children'}); + }); + + test('should show a "no results" label', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.noResultsLabel.first() + ).toBeVisible(); + }); + + test('should NOT show the "load all results" button', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.loadAllResultsButton.first() + ).not.toBeVisible(); + }); + + test('should NOT show result children', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.resultChildren.first() + ).not.toBeVisible(); + }); + }); + + test.describe('when more results are NOT available & there are result children', () => { + test.beforeEach(async ({insightFoldedResultList}) => { + await insightFoldedResultList.load({story: 'with-few-result-children'}); + }); + + test('should show result children', async ({insightFoldedResultList}) => { + await expect( + insightFoldedResultList.resultChildren.first() + ).toBeVisible(); + }); + + test('should NOT show a "no results" label', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.noResultsLabel.first() + ).not.toBeVisible(); + }); + + test('should NOT show "load all results" button', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.loadAllResultsButton.first() + ).not.toBeVisible(); + }); + }); + + test.describe('when more results are available & there are result children', () => { + test.beforeEach(async ({insightFoldedResultList}) => { + await insightFoldedResultList.load(); + }); + + test('should show the "load all results" button', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.loadAllResultsButton.first() + ).toBeVisible(); + }); + + test('should show the "Collapse results" button after loading all results', async ({ + insightFoldedResultList, + }) => { + await insightFoldedResultList.loadAllResultsButton.first().click(); + await expect( + insightFoldedResultList.collapseResultsButton.first() + ).toBeVisible(); + }); + + test('should show result children', async ({insightFoldedResultList}) => { + await expect( + insightFoldedResultList.resultChildren.first() + ).toBeVisible(); + }); + + test('should NOT show the "no results" label', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.noResultsLabel.first() + ).not.toBeVisible(); + }); + }); + + test.describe('when more results are available & there are NO result children', () => { + test.beforeEach(async ({insightFoldedResultList}) => { + await insightFoldedResultList.load({ + story: 'with-more-results-available-and-no-children', + }); + }); + + test('should show the "load all results" button', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.loadAllResultsButton.first() + ).toBeVisible(); + }); + + test('should NOT show result children', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.resultChildren.first() + ).not.toBeVisible(); + }); + + test('should NOT show the "no results" label', async ({ + insightFoldedResultList, + }) => { + await expect( + insightFoldedResultList.noResultsLabel.first() + ).not.toBeVisible(); + }); + }); +}); diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/fixture.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/fixture.ts new file mode 100644 index 00000000000..55ba4e7dff8 --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/fixture.ts @@ -0,0 +1,14 @@ +import {test as base} from '@playwright/test'; +import {AtomicInsightFoldedResultListPageObject as InsightFoldedResultList} from './page-object'; + +type Fixture = { + insightFoldedResultList: InsightFoldedResultList; +}; + +export const test = base.extend({ + insightFoldedResultList: async ({page}, use) => { + await use(new InsightFoldedResultList(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/page-object.ts b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/page-object.ts new file mode 100644 index 00000000000..54597eef85e --- /dev/null +++ b/packages/atomic/src/components/insight/result-lists/atomic-insight-folded-result-list/e2e/page-object.ts @@ -0,0 +1,24 @@ +import type {Page} from '@playwright/test'; +import {BasePageObject} from '@/playwright-utils/lit-base-page-object'; + +export class AtomicInsightFoldedResultListPageObject extends BasePageObject { + constructor(page: Page) { + super(page, 'atomic-insight-folded-result-list'); + } + + get noResultsLabel() { + return this.page.locator('[part="no-result-root"]'); + } + + get loadAllResultsButton() { + return this.page.getByRole('button', {name: 'Load all results'}); + } + + get collapseResultsButton() { + return this.page.getByRole('button', {name: 'Collapse results'}); + } + + get resultChildren() { + return this.page.locator('[part="children-root"]'); + } +} diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 85485a08de3..efe049b86f6 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -57,6 +57,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-generated-answer', 'atomic-html', 'atomic-icon', + 'atomic-insight-folded-result-list', 'atomic-insight-generate-answer-button', 'atomic-insight-interface', 'atomic-insight-tab', diff --git a/packages/atomic/storybook-utils/api/insight/search-response.ts b/packages/atomic/storybook-utils/api/insight/search-response.ts index 73d6af4f275..279703b8298 100644 --- a/packages/atomic/storybook-utils/api/insight/search-response.ts +++ b/packages/atomic/storybook-utils/api/insight/search-response.ts @@ -279,3 +279,98 @@ export const richResponse: InsightResponse = { }, })), }; + +export const baseFoldedResponse: InsightResponse = { + ...baseResponse, + totalCount: 2, + totalCountFiltered: 2, + results: [ + { + title: 'Knowledge Base Articles', + excerpt: 'Collection of support articles', + clickUri: 'https://support.example.com/kb', + printableUri: 'https://support.example.com/kb', + uri: 'support://kb', + uniqueId: 'kb-parent', + flags: 'HasHtmlVersion', + hasHtmlVersion: true, + hasMobileHtmlVersion: false, + score: 1000, + percentScore: 100, + rankingInfo: null, + rating: 0, + isTopResult: true, + isRecommendation: false, + isUserActionView: false, + titleHighlights: [], + firstSentencesHighlights: [], + excerptHighlights: [], + printableUriHighlights: [], + summaryHighlights: [], + parentResult: null, + totalNumberOfChildResults: 2, + childResults: [ + { + title: 'Troubleshooting Network Issues', + excerpt: 'How to troubleshoot network connectivity problems', + clickUri: 'https://support.example.com/kb/network', + uniqueId: 'kb-network-child', + raw: { + foldingcollection: 'Knowledge Base', + foldingchild: ['network'], + foldingparent: 'kb', + }, + }, + { + title: 'System Performance Tips', + excerpt: 'Best practices for optimizing system performance', + clickUri: 'https://support.example.com/kb/performance', + uniqueId: 'kb-performance-child', + raw: { + foldingcollection: 'Knowledge Base', + foldingchild: ['performance'], + foldingparent: 'kb', + }, + }, + ], + absentTerms: [], + raw: { + systitle: 'Knowledge Base Articles', + sysdescription: 'Collection of support articles', + sysuri: 'https://support.example.com/kb', + sysauthor: ['Support Team'], + sysurihash: 'kb-hash', + urihash: 'kb-hash', + permanentid: 'kb-perm-id', + syslanguage: ['English'], + date: Date.now(), + sourcetype: 'KnowledgeBase', + syssource: 'Support Knowledge Base', + sysdate: Date.now(), + author: ['Support Team'], + source: 'Support Knowledge Base', + collection: 'default', + syssourcetype: 'KnowledgeBase', + filetype: 'html', + sysfiletype: 'html', + language: ['English'], + syscollection: 'default', + foldingcollection: 'Knowledge Base', + foldingparent: 'kb', + }, + Title: 'Knowledge Base Articles', + Uri: 'support://kb', + PrintableUri: 'https://support.example.com/kb', + ClickUri: 'https://support.example.com/kb', + UniqueId: 'kb-parent', + Excerpt: 'Collection of support articles', + FirstSentences: null, + }, + { + ...getNthResult(10), + parentResult: null, + childResults: [], + totalNumberOfChildResults: 0, + }, + ], +};