From 18a9b2cf5dc31449d126ef994c641c42071b825d Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 3 Aug 2026 20:10:01 -0400 Subject: [PATCH 1/2] feat(quantic): add quantic-load-more-results component Adds the quantic-load-more-results component, an alternative to quantic-pager, that lets users load additional results by clicking a button. It shows a running summary (X of Y results), a progress bar, and announces via aria-live when the last batch has been loaded. - New production component quanticLoadMoreResults, built on the existing headless ResultList/QuerySummary controllers (no per-engine branching needed for Search vs Insight). - New example-community wrapper for E2E coverage and manual testing. - New labels (EN/FR/ES) for the button, running summary, and aria-live announcement. - JSDoc cross-reference added to quantic-pager documenting the two components are alternatives, not to be combined. - Adds SearchObject.mockSearchWithLoadMoreSequence(), a small e2e test helper needed to simulate a load-more response sequence. --- .changeset/quantic-load-more-results.md | 5 + .../exampleQuanticLoadMoreResults.html | 18 ++ .../exampleQuanticLoadMoreResults.js | 25 ++ .../exampleQuanticLoadMoreResults.js-meta.xml | 9 + .../labels/CustomLabels.labels-meta.xml | 35 +++ .../__tests__/quanticLoadMoreResults.test.js | 241 ++++++++++++++++++ .../lwc/quanticLoadMoreResults/e2e/fixture.ts | 59 +++++ .../quanticLoadMoreResults/e2e/pageObject.ts | 44 ++++ .../e2e/quanticLoadMoreResults.e2e.ts | 70 +++++ .../quanticLoadMoreResults.css | 13 + .../quanticLoadMoreResults.html | 30 +++ .../quanticLoadMoreResults.js | 165 ++++++++++++ .../quanticLoadMoreResults.js-meta.xml | 5 + .../default/lwc/quanticPager/quanticPager.js | 2 + .../main/translations/es.translation-meta.xml | 20 ++ .../main/translations/fr.translation-meta.xml | 20 ++ .../playwright/page-object/searchObject.ts | 29 +++ 17 files changed, 790 insertions(+) create mode 100644 .changeset/quantic-load-more-results.md create mode 100644 packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.html create mode 100644 packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js create mode 100644 packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js-meta.xml create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/__tests__/quanticLoadMoreResults.test.js create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/fixture.ts create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/pageObject.ts create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/quanticLoadMoreResults.e2e.ts create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.html create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js create mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml diff --git a/.changeset/quantic-load-more-results.md b/.changeset/quantic-load-more-results.md new file mode 100644 index 00000000000..52d0e91b55c --- /dev/null +++ b/.changeset/quantic-load-more-results.md @@ -0,0 +1,5 @@ +--- +'@coveo/quantic': minor +--- + +Added the `quantic-load-more-results` component, allowing users to load additional results, if more are available, by clicking a button. It displays a running count of results shown versus the total available, and a progress bar, and announces through an aria-live region when the last batch of results has been loaded. This is an alternative to `quantic-pager`; use one or the other, not both on the same page. diff --git a/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.html b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.html new file mode 100644 index 00000000000..8e4bdb1c1d7 --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.html @@ -0,0 +1,18 @@ + diff --git a/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js new file mode 100644 index 00000000000..9e888f038ce --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js @@ -0,0 +1,25 @@ +import {api, LightningElement, track} from 'lwc'; + +export default class ExampleQuanticLoadMoreResults extends LightningElement { + @api engineId = 'quantic-load-more-results-engine'; + @track config = {}; + isConfigured = false; + + pageTitle = 'Quantic Load More Results'; + pageDescription = + 'The Quantic Load More Results component allows users to load additional results, if more are available, by clicking a button.'; + options = [ + { + attribute: 'useCase', + label: 'Use Case', + description: + 'Define which use case to test. Possible values are: search, insight', + defaultValue: 'search', + }, + ]; + + handleTryItNow(evt) { + this.config = evt.detail; + this.isConfigured = true; + } +} diff --git a/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js-meta.xml b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js-meta.xml new file mode 100644 index 00000000000..44f9004fefc --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js-meta.xml @@ -0,0 +1,9 @@ + + + 64.0 + true + + lightningCommunity__Page + lightningCommunity__Default + + diff --git a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml index bdecb826e6c..257952620aa 100644 --- a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml +++ b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml @@ -56,6 +56,41 @@ false No Results for potato + + quantic_LoadMoreResults + Load more results + en_US + false + Label of the load more results button + + + quantic_ShowingResultsOfLoadMore + Showing {{0}} of {{1}} result + en_US + false + ex: Showing 1 of 123 result + + + quantic_ShowingResultsOfLoadMore_plural + Showing {{0}} of {{1}} results + en_US + false + ex: Showing 10 of 123 results + + + quantic_ShowingResultsOfLoadMore_zero + No results + en_US + false + No Results + + + quantic_AllResultsLoaded + All results have been loaded + en_US + false + Aria-live announcement when the last batch of results has been loaded + quantic_Clear Clear diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/__tests__/quanticLoadMoreResults.test.js b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/__tests__/quanticLoadMoreResults.test.js new file mode 100644 index 00000000000..767bb0285fa --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/__tests__/quanticLoadMoreResults.test.js @@ -0,0 +1,241 @@ +jest.mock('c/quanticHeadlessLoader'); +jest.mock('c/quanticUtils'); + +import QuanticLoadMoreResults from 'c/quanticLoadMoreResults'; +import {buildCreateTestComponent, cleanup, flushPromises} from 'c/testUtils'; +import * as quanticHeadlessLoader from 'c/quanticHeadlessLoader'; +import * as quanticUtils from 'c/quanticUtils'; + +const headlessLoaderMock = jest.mocked(quanticHeadlessLoader); +const engineMock = { + id: 'mockEngine', + dispatch: jest.fn(), +}; + +const functionsMocks = { + resultListSubscribe: jest.fn((cb) => { + cb(); + return functionsMocks.resultListUnsubscribe; + }), + resultListUnsubscribe: jest.fn(() => {}), + querySummarySubscribe: jest.fn((cb) => { + cb(); + return functionsMocks.querySummaryUnsubscribe; + }), + querySummaryUnsubscribe: jest.fn(() => {}), + fetchMoreResults: jest.fn(), + dispatchMessage: jest.fn(), +}; + +const resultListControllerMock = { + subscribe: functionsMocks.resultListSubscribe, + fetchMoreResults: functionsMocks.fetchMoreResults, +}; + +const querySummaryControllerMock = { + subscribe: functionsMocks.querySummarySubscribe, +}; + +const headlessMock = { + buildResultList: jest.fn().mockReturnValue(resultListControllerMock), + buildQuerySummary: jest.fn().mockReturnValue(querySummaryControllerMock), +}; +headlessLoaderMock.getHeadlessBundle.mockReturnValue(headlessMock); +headlessLoaderMock.initializeWithHeadless.mockImplementation( + async (element, _, initialize) => { + if (element instanceof QuanticLoadMoreResults) { + initialize(engineMock); + } + } +); + +const quanticUtilsMock = jest.mocked(quanticUtils); +quanticUtilsMock.AriaLiveRegion.mockReturnValue({ + dispatchMessage: functionsMocks.dispatchMessage, + registerRegion: undefined, +}); +quanticUtilsMock.I18nUtils.getLabelNameWithCount.mockImplementation( + (baseName, count) => { + if (!count) { + return `${baseName}_zero`; + } + if (count > 1) { + return `${baseName}_plural`; + } + return baseName; + } +); +quanticUtilsMock.I18nUtils.format.mockImplementation( + (str, ...args) => `${str} ${args.join(' ')}` +); + +const selectors = { + componentError: 'c-quantic-component-error', + summary: 'lightning-formatted-rich-text', + progressBar: '.load-more-results__progress-bar-fill', + loadMoreButton: 'button.load-more-results__button', +}; + +const createTestComponent = buildCreateTestComponent( + QuanticLoadMoreResults, + 'c-quantic-load-more-results', + {engineId: engineMock.id} +); + +function setState({ + moreResultsAvailable = true, + hasResults = true, + lastResult = 10, + total = 100, + searchResponseId = 'response-1', +} = {}) { + resultListControllerMock.state = { + moreResultsAvailable, + searchResponseId, + }; + querySummaryControllerMock.state = { + hasResults, + lastResult, + total, + }; +} + +describe('c-quantic-load-more-results', () => { + beforeEach(() => { + setState(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should build the result list and query summary controllers and subscribe to their state', async () => { + createTestComponent(); + await flushPromises(); + + expect(headlessLoaderMock.getHeadlessBundle).toHaveBeenCalledWith( + engineMock.id + ); + expect(headlessMock.buildResultList).toHaveBeenCalledWith(engineMock); + expect(headlessMock.buildQuerySummary).toHaveBeenCalledWith(engineMock); + expect(resultListControllerMock.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ); + expect(querySummaryControllerMock.subscribe).toHaveBeenCalledWith( + expect.any(Function) + ); + }); + + describe('when there is an initialization error', () => { + it('should display the initialization error component', async () => { + headlessLoaderMock.initializeWithHeadless.mockImplementationOnce( + async (element) => { + if (element instanceof QuanticLoadMoreResults) { + element.setInitializationError(); + } + } + ); + + const element = createTestComponent(); + await flushPromises(); + + const error = element.shadowRoot.querySelector(selectors.componentError); + expect(error).not.toBeNull(); + }); + }); + + describe('when there are no results', () => { + it('should render nothing', async () => { + setState({hasResults: false}); + const element = createTestComponent(); + await flushPromises(); + + const summary = element.shadowRoot.querySelector(selectors.summary); + const button = element.shadowRoot.querySelector(selectors.loadMoreButton); + expect(summary).toBeNull(); + expect(button).toBeNull(); + }); + }); + + describe('when more results are available', () => { + it('should render the summary, progress bar, and load more button', async () => { + setState({moreResultsAvailable: true, lastResult: 10, total: 100}); + const element = createTestComponent(); + await flushPromises(); + + const summary = element.shadowRoot.querySelector(selectors.summary); + const progressBar = element.shadowRoot.querySelector( + selectors.progressBar + ); + const button = element.shadowRoot.querySelector(selectors.loadMoreButton); + expect(summary).not.toBeNull(); + expect(progressBar).not.toBeNull(); + expect(progressBar.style.width).toBe('10%'); + expect(button).not.toBeNull(); + }); + + it('should call fetchMoreResults when the button is clicked', async () => { + setState({moreResultsAvailable: true}); + const element = createTestComponent(); + await flushPromises(); + + const button = element.shadowRoot.querySelector(selectors.loadMoreButton); + button.click(); + + expect(functionsMocks.fetchMoreResults).toHaveBeenCalledTimes(1); + }); + }); + + describe('when no more results are available', () => { + it('should not render the load more button', async () => { + setState({moreResultsAvailable: false, lastResult: 100, total: 100}); + const element = createTestComponent(); + await flushPromises(); + + const button = element.shadowRoot.querySelector(selectors.loadMoreButton); + expect(button).toBeNull(); + }); + }); + + describe('when the last batch of results has just been loaded', () => { + it('should dispatch the all-results-loaded aria-live message', async () => { + setState({ + moreResultsAvailable: true, + searchResponseId: 'response-1', + }); + createTestComponent(); + await flushPromises(); + + functionsMocks.dispatchMessage.mockClear(); + + resultListControllerMock.state = { + moreResultsAvailable: false, + searchResponseId: 'response-1', + }; + functionsMocks.resultListSubscribe.mock.calls[0][0](); + + expect(functionsMocks.dispatchMessage).toHaveBeenCalledWith( + 'c.quantic_AllResultsLoaded' + ); + }); + + it('should not dispatch the message when the transition is caused by a new search', async () => { + setState({ + moreResultsAvailable: true, + searchResponseId: 'response-1', + }); + createTestComponent(); + await flushPromises(); + + functionsMocks.dispatchMessage.mockClear(); + + resultListControllerMock.state = { + moreResultsAvailable: false, + searchResponseId: 'response-2', + }; + functionsMocks.resultListSubscribe.mock.calls[0][0](); + + expect(functionsMocks.dispatchMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/fixture.ts b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/fixture.ts new file mode 100644 index 00000000000..c714a7154bb --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/fixture.ts @@ -0,0 +1,59 @@ +import {LoadMoreResultsObject} from './pageObject'; +import {quanticBase} from '../../../../../../playwright/fixtures/baseFixture'; +import {InsightSetupObject} from '../../../../../../playwright/page-object/insightSetupObject'; +import {SearchObject} from '../../../../../../playwright/page-object/searchObject'; +import { + searchRequestRegex, + insightSearchRequestRegex, +} from '../../../../../../playwright/utils/requests'; +import {useCaseEnum} from '../../../../../../playwright/utils/useCase'; + +const loadMoreResultsUrl = 's/quantic-load-more-results'; + +type QuanticLoadMoreResultsE2EFixtures = { + loadMoreResults: LoadMoreResultsObject; + search: SearchObject; +}; + +type QuanticLoadMoreResultsE2EInsightFixtures = + QuanticLoadMoreResultsE2EFixtures & { + insightSetup: InsightSetupObject; + }; + +export const testSearch = + quanticBase.extend({ + search: async ({page}, use) => { + await use(new SearchObject(page, searchRequestRegex)); + }, + loadMoreResults: async ({page, configuration, search}, use) => { + await search.mockSearchWithLoadMoreSequence(); + await page.goto(loadMoreResultsUrl); + configuration.configure({}); + await search.waitForSearchResponse(); + await use(new LoadMoreResultsObject(page)); + }, + }); + +export const testInsight = + quanticBase.extend({ + search: async ({page}, use) => { + await use(new SearchObject(page, insightSearchRequestRegex)); + }, + insightSetup: async ({page}, use) => { + await use(new InsightSetupObject(page)); + }, + loadMoreResults: async ( + {page, search, configuration, insightSetup}, + use + ) => { + await search.mockSearchWithLoadMoreSequence(); + await page.goto(loadMoreResultsUrl); + configuration.configure({useCase: useCaseEnum.insight}); + await insightSetup.waitForInsightInterfaceInitialization(); + await Promise.all([ + search.waitForSearchResponse(), + search.performSearch(), + ]); + await use(new LoadMoreResultsObject(page)); + }, + }); diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/pageObject.ts b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/pageObject.ts new file mode 100644 index 00000000000..6271a85e856 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/pageObject.ts @@ -0,0 +1,44 @@ +import type {Locator, Page} from '@playwright/test'; + +const loadMoreResultsElementsSelectors = { + component: 'c-quantic-load-more-results', + componentError: 'c-quantic-component-error', + summary: 'c-quantic-load-more-results lightning-formatted-rich-text', + progressBar: 'c-quantic-load-more-results .load-more-results__progress-bar', + loadMoreButton: 'c-quantic-load-more-results button', + results: 'c-quantic-result', +}; + +export class LoadMoreResultsObject { + constructor(public page: Page) { + this.page = page; + } + + get component(): Locator { + return this.page.locator(loadMoreResultsElementsSelectors.component); + } + + get componentError(): Locator { + return this.page.locator(loadMoreResultsElementsSelectors.componentError); + } + + get summary(): Locator { + return this.page.locator(loadMoreResultsElementsSelectors.summary); + } + + get progressBar(): Locator { + return this.page.locator(loadMoreResultsElementsSelectors.progressBar); + } + + get loadMoreButton(): Locator { + return this.page.locator(loadMoreResultsElementsSelectors.loadMoreButton); + } + + get results(): Locator { + return this.page.locator(loadMoreResultsElementsSelectors.results); + } + + async clickLoadMoreButton(): Promise { + await this.loadMoreButton.click(); + } +} diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/quanticLoadMoreResults.e2e.ts b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/quanticLoadMoreResults.e2e.ts new file mode 100644 index 00000000000..2ff1eaf657c --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/quanticLoadMoreResults.e2e.ts @@ -0,0 +1,70 @@ +import {testSearch, testInsight} from './fixture'; +import { + useCaseEnum, + useCaseTestCases, +} from '../../../../../../playwright/utils/useCase'; + +const fixtures = { + search: testSearch, + insight: testInsight, +}; + +useCaseTestCases.forEach((useCase) => { + let test = fixtures[useCase.value]; + + test.describe(`quantic load more results ${useCase.label}`, () => { + test('should load as expected', async ({loadMoreResults}) => { + await test.expect(loadMoreResults.component).toBeVisible(); + await test.expect(loadMoreResults.summary).toBeVisible(); + await test.expect(loadMoreResults.progressBar).toBeVisible(); + await test.expect(loadMoreResults.loadMoreButton).toBeVisible(); + }); + + test.describe('when clicking the load more button', () => { + test('should append results and hide the button once all results are loaded', async ({ + loadMoreResults, + search, + }) => { + const initialResultsCount = await loadMoreResults.results.count(); + + const searchResponsePromise = search.waitForSearchResponse(); + await loadMoreResults.clickLoadMoreButton(); + await searchResponsePromise; + + await test + .expect(loadMoreResults.results) + .toHaveCount(initialResultsCount * 2); + await test.expect(loadMoreResults.loadMoreButton).toBeHidden(); + }); + + test('should announce that all results have been loaded', async ({ + loadMoreResults, + page, + search, + }) => { + const ariaLiveRegion = page.locator('[aria-live]'); + + const searchResponsePromise = search.waitForSearchResponse(); + await loadMoreResults.clickLoadMoreButton(); + await searchResponsePromise; + + await test + .expect(ariaLiveRegion) + .toContainText('All results have been loaded'); + }); + }); + + if (useCase.value === useCaseEnum.search) { + test.describe('when there are no results', () => { + test.beforeEach(async ({search}) => { + await search.mockEmptySearchResponse(); + }); + + test('should not render anything', async ({page}) => { + const component = page.locator('c-quantic-load-more-results'); + await test.expect(component).toBeEmpty(); + }); + }); + } + }); +}); diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css new file mode 100644 index 00000000000..94b4a873a9c --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css @@ -0,0 +1,13 @@ +.load-more-results__progress-bar { + width: 100%; + height: 0.25rem; + border-radius: var(--lwc-borderRadiusMedium, 0.25rem); + background-color: var(--lwc-colorBackgroundRowHover, #f3f3f3); + overflow: hidden; + margin: var(--lwc-spacingSmall, 0.5rem) 0; +} + +.load-more-results__progress-bar-fill { + height: 100%; + background-color: var(--lwc-brandPrimary, #1b96ff); +} diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.html b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.html new file mode 100644 index 00000000000..b312a97f58f --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.html @@ -0,0 +1,30 @@ + diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js new file mode 100644 index 00000000000..3ea152a2c79 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js @@ -0,0 +1,165 @@ +import allResultsLoaded from '@salesforce/label/c.quantic_AllResultsLoaded'; +import loadMoreResults from '@salesforce/label/c.quantic_LoadMoreResults'; +import showingResultsOfLoadMore from '@salesforce/label/c.quantic_ShowingResultsOfLoadMore'; +import showingResultsOfLoadMore_plural from '@salesforce/label/c.quantic_ShowingResultsOfLoadMore_plural'; +import showingResultsOfLoadMore_zero from '@salesforce/label/c.quantic_ShowingResultsOfLoadMore_zero'; +import { + registerComponentForInit, + initializeWithHeadless, + getHeadlessBundle, +} from 'c/quanticHeadlessLoader'; +import {AriaLiveRegion, I18nUtils} from 'c/quanticUtils'; +import {LightningElement, api, track} from 'lwc'; + +/** @typedef {import("coveo").SearchEngine} SearchEngine */ +/** @typedef {import("coveo").ResultList} ResultList */ +/** @typedef {import("coveo").ResultListState} ResultListState */ +/** @typedef {import("coveo").QuerySummary} QuerySummary */ +/** @typedef {import("coveo").QuerySummaryState} QuerySummaryState */ + +/** + * The `QuanticLoadMoreResults` component allows the user to load additional results if more are available. + * + * This component is an alternative to the `quantic-pager` component: use one or the other, not both on the same page. + * @category Search + * @category Insight Panel + * @example + * + */ +export default class QuanticLoadMoreResults extends LightningElement { + /** + * The ID of the engine instance the component registers to. + * @api + * @type {string} + */ + @api engineId; + + /** @type {ResultListState} */ + @track resultListState; + /** @type {QuerySummaryState} */ + @track querySummaryState; + + /** @type {ResultList} */ + resultList; + /** @type {QuerySummary} */ + querySummary; + /** @type {Function} */ + unsubscribeResultList; + /** @type {Function} */ + unsubscribeQuerySummary; + /** @type {AnyHeadless} */ + headless; + /** @type {import('c/quanticUtils').AriaLiveUtils} */ + allResultsLoadedAriaMessage; + /** @type {boolean} */ + hasInitializationError = false; + /** @type {ResultListState} */ + previousResultListState; + + labels = { + allResultsLoaded, + loadMoreResults, + showingResultsOfLoadMore, + showingResultsOfLoadMore_plural, + showingResultsOfLoadMore_zero, + }; + + connectedCallback() { + registerComponentForInit(this, this.engineId); + } + + renderedCallback() { + initializeWithHeadless(this, this.engineId, this.initialize); + } + + /** + * @param {SearchEngine} engine + */ + initialize = (engine) => { + this.headless = getHeadlessBundle(this.engineId); + this.resultList = this.headless.buildResultList(engine); + this.querySummary = this.headless.buildQuerySummary(engine); + this.allResultsLoadedAriaMessage = AriaLiveRegion( + 'load-more-results', + this + ); + this.unsubscribeResultList = this.resultList.subscribe(() => + this.updateResultListState() + ); + this.unsubscribeQuerySummary = this.querySummary.subscribe(() => + this.updateQuerySummaryState() + ); + }; + + disconnectedCallback() { + this.unsubscribeResultList?.(); + this.unsubscribeQuerySummary?.(); + } + + updateResultListState() { + this.previousResultListState = this.resultListState; + this.resultListState = this.resultList.state; + this.announceWhenAllResultsAreLoaded(); + } + + updateQuerySummaryState() { + this.querySummaryState = this.querySummary.state; + } + + announceWhenAllResultsAreLoaded() { + const justLoadedLastBatch = + this.previousResultListState?.moreResultsAvailable === true && + this.resultListState?.moreResultsAvailable === false && + this.previousResultListState?.searchResponseId === + this.resultListState?.searchResponseId; + + if (justLoadedLastBatch) { + this.allResultsLoadedAriaMessage.dispatchMessage( + this.labels.allResultsLoaded + ); + } + } + + get hasResults() { + return this.querySummaryState?.hasResults; + } + + get moreResultsAvailable() { + return this.resultListState?.moreResultsAvailable; + } + + get progressPercentage() { + const total = this.querySummaryState?.total; + if (!total) { + return 0; + } + return Math.min((this.querySummaryState.lastResult / total) * 100, 100); + } + + get progressBarStyle() { + return `width: ${this.progressPercentage}%`; + } + + get summaryLabel() { + const labelName = I18nUtils.getLabelNameWithCount( + 'showingResultsOfLoadMore', + this.querySummaryState?.lastResult + ); + return I18nUtils.format( + this.labels[labelName], + Intl.NumberFormat().format(this.querySummaryState?.lastResult), + Intl.NumberFormat().format(this.querySummaryState?.total) + ); + } + + fetchMoreResults() { + this.resultList.fetchMoreResults(); + } + + /** + * Sets the component in the initialization error state. + */ + setInitializationError() { + this.hasInitializationError = true; + } +} diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml new file mode 100644 index 00000000000..f37569dbb15 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + false + \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/lwc/quanticPager/quanticPager.js b/packages/quantic/force-app/main/default/lwc/quanticPager/quanticPager.js index 380005f4a1e..bd5455f8ee2 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticPager/quanticPager.js +++ b/packages/quantic/force-app/main/default/lwc/quanticPager/quanticPager.js @@ -14,6 +14,8 @@ import {LightningElement, api, track} from 'lwc'; /** * The `QuanticPager` provides buttons that allow the end user to navigate through the different result pages. + * + * This component is an alternative to the `quantic-load-more-results` component: use one or the other, not both on the same page. * @category Search * @category Insight Panel * @example diff --git a/packages/quantic/force-app/main/translations/es.translation-meta.xml b/packages/quantic/force-app/main/translations/es.translation-meta.xml index cf234cc0464..2b0bc35bb49 100644 --- a/packages/quantic/force-app/main/translations/es.translation-meta.xml +++ b/packages/quantic/force-app/main/translations/es.translation-meta.xml @@ -32,6 +32,26 @@ quantic_NoResultsFor + + + quantic_LoadMoreResults + + + + quantic_ShowingResultsOfLoadMore + + + + quantic_ShowingResultsOfLoadMore_plural + + + + quantic_ShowingResultsOfLoadMore_zero + + + + quantic_AllResultsLoaded + quantic_ClearAllFilters diff --git a/packages/quantic/force-app/main/translations/fr.translation-meta.xml b/packages/quantic/force-app/main/translations/fr.translation-meta.xml index 6edfa7311eb..7ecf1b3abbd 100644 --- a/packages/quantic/force-app/main/translations/fr.translation-meta.xml +++ b/packages/quantic/force-app/main/translations/fr.translation-meta.xml @@ -32,6 +32,26 @@ quantic_NoResultsFor + + + quantic_LoadMoreResults + + + + quantic_ShowingResultsOfLoadMore + + + + quantic_ShowingResultsOfLoadMore_plural + + + + quantic_ShowingResultsOfLoadMore_zero + + + + quantic_AllResultsLoaded + quantic_ClearAllFilters diff --git a/packages/quantic/playwright/page-object/searchObject.ts b/packages/quantic/playwright/page-object/searchObject.ts index 62e7ef54a9f..bad0772de54 100644 --- a/packages/quantic/playwright/page-object/searchObject.ts +++ b/packages/quantic/playwright/page-object/searchObject.ts @@ -178,6 +178,35 @@ export class SearchObject { }); } + /** + * Mocks the search endpoint so that the first response reports more + * results are available, and every subsequent response (i.e. after a + * "load more" fetch) reports all results have been loaded. + */ + async mockSearchWithLoadMoreSequence(): Promise { + await this.page.unroute(this.searchRequestRegex); + let hasRespondedOnce = false; + await this.page.route(this.searchRequestRegex, async (route) => { + const originalBody = searchResponses.richResponse; + const totalCount = hasRespondedOnce + ? originalBody.results.length + : originalBody.results.length * 2; + hasRespondedOnce = true; + + await route.fulfill({ + body: JSON.stringify({ + ...originalBody, + totalCount, + totalCountFiltered: totalCount, + }), + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + }); + } + extractDataFromResponse(response: Response) { return response.request().postDataJSON(); } From 9b040ecf9ba7f06e9854608f5d3ef738ef5ff3a6 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 3 Aug 2026 20:25:53 -0400 Subject: [PATCH 2/2] fix(quantic): correct load-more e2e mock and dead label in load-more-results Fixes PR review findings on the quantic-load-more-results feature: - mockSearchWithLoadMoreSequence now returns a distinct batch of result uniqueIds per response instead of repeating the same results array, matching how a real load-more response differs from the initial page. This avoids duplicate for:each keys in quanticResultList (searchResponseId + uniqueId), which previously masked whether the e2e append/aria-live assertions were exercising real behavior. - Removed the unreachable _zero variant of the quantic_ShowingResultsOfLoadMore label (and its fr/es translations): the summary block only renders when querySummaryState.hasResults is true, which is false whenever lastResult is 0, so the _zero string could never be shown. - Added a trailing newline to quanticLoadMoreResults.js-meta.xml to match sibling generated meta.xml files. --- .../labels/CustomLabels.labels-meta.xml | 7 ------ .../quanticLoadMoreResults.js | 2 -- .../quanticLoadMoreResults.js-meta.xml | 2 +- .../main/translations/es.translation-meta.xml | 4 ---- .../main/translations/fr.translation-meta.xml | 4 ---- .../playwright/page-object/searchObject.ts | 23 ++++++++++++++----- 6 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml index 257952620aa..7c2f98618d4 100644 --- a/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml +++ b/packages/quantic/force-app/main/default/labels/CustomLabels.labels-meta.xml @@ -77,13 +77,6 @@ false ex: Showing 10 of 123 results - - quantic_ShowingResultsOfLoadMore_zero - No results - en_US - false - No Results - quantic_AllResultsLoaded All results have been loaded diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js index 3ea152a2c79..606a289bf69 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js @@ -2,7 +2,6 @@ import allResultsLoaded from '@salesforce/label/c.quantic_AllResultsLoaded'; import loadMoreResults from '@salesforce/label/c.quantic_LoadMoreResults'; import showingResultsOfLoadMore from '@salesforce/label/c.quantic_ShowingResultsOfLoadMore'; import showingResultsOfLoadMore_plural from '@salesforce/label/c.quantic_ShowingResultsOfLoadMore_plural'; -import showingResultsOfLoadMore_zero from '@salesforce/label/c.quantic_ShowingResultsOfLoadMore_zero'; import { registerComponentForInit, initializeWithHeadless, @@ -61,7 +60,6 @@ export default class QuanticLoadMoreResults extends LightningElement { loadMoreResults, showingResultsOfLoadMore, showingResultsOfLoadMore_plural, - showingResultsOfLoadMore_zero, }; connectedCallback() { diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml index f37569dbb15..9176009d178 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml @@ -2,4 +2,4 @@ 64.0 false - \ No newline at end of file + diff --git a/packages/quantic/force-app/main/translations/es.translation-meta.xml b/packages/quantic/force-app/main/translations/es.translation-meta.xml index 2b0bc35bb49..9e6fab9a8d1 100644 --- a/packages/quantic/force-app/main/translations/es.translation-meta.xml +++ b/packages/quantic/force-app/main/translations/es.translation-meta.xml @@ -44,10 +44,6 @@ quantic_ShowingResultsOfLoadMore_plural - - - quantic_ShowingResultsOfLoadMore_zero - quantic_AllResultsLoaded diff --git a/packages/quantic/force-app/main/translations/fr.translation-meta.xml b/packages/quantic/force-app/main/translations/fr.translation-meta.xml index 7ecf1b3abbd..e54420d9991 100644 --- a/packages/quantic/force-app/main/translations/fr.translation-meta.xml +++ b/packages/quantic/force-app/main/translations/fr.translation-meta.xml @@ -44,10 +44,6 @@ quantic_ShowingResultsOfLoadMore_plural - - - quantic_ShowingResultsOfLoadMore_zero - quantic_AllResultsLoaded diff --git a/packages/quantic/playwright/page-object/searchObject.ts b/packages/quantic/playwright/page-object/searchObject.ts index bad0772de54..f06fa2c1ee0 100644 --- a/packages/quantic/playwright/page-object/searchObject.ts +++ b/packages/quantic/playwright/page-object/searchObject.ts @@ -181,21 +181,32 @@ export class SearchObject { /** * Mocks the search endpoint so that the first response reports more * results are available, and every subsequent response (i.e. after a - * "load more" fetch) reports all results have been loaded. + * "load more" fetch) reports all results have been loaded. Each response + * beyond the first returns a distinct batch of results (`uniqueId`s + * suffixed by response index), matching how a real "load more" batch + * differs from the initial page. `searchUid` is kept constant across the + * sequence, matching headless's `fetchMoreResults` behavior, which does + * not update `searchResponseId` on subsequent batches. */ async mockSearchWithLoadMoreSequence(): Promise { await this.page.unroute(this.searchRequestRegex); - let hasRespondedOnce = false; + let responseIndex = 0; await this.page.route(this.searchRequestRegex, async (route) => { const originalBody = searchResponses.richResponse; - const totalCount = hasRespondedOnce - ? originalBody.results.length - : originalBody.results.length * 2; - hasRespondedOnce = true; + const totalCount = + responseIndex === 0 + ? originalBody.results.length * 2 + : originalBody.results.length; + const results = originalBody.results.map((result) => ({ + ...result, + uniqueId: `${result.uniqueId}-batch${responseIndex}`, + })); + responseIndex += 1; await route.fulfill({ body: JSON.stringify({ ...originalBody, + results, totalCount, totalCountFiltered: totalCount, }),