From c60397ae7f462cefce74a969d96825da95c32b51 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Tue, 28 Jul 2026 16:00:58 -0400 Subject: [PATCH 1/3] theme set --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 69fad7c4fba..352f4fe6115 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ -{ +{ "workbench.colorTheme": "Vue Theme", "chat.useAgentSkills": true, "json.schemaDownload.trustedDomains": { "https://opencode.ai/config.json": true, From c8f07819309d8a5edd800d17b4eef129cd25b622 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Fri, 31 Jul 2026 00:16:28 -0400 Subject: [PATCH 2/3] feat(quantic): add quantic-load-more-results component Adds a new Quantic LWC component that lets end users load additional results into the current result list without navigating to a new page, working identically for Search and Insight Panel interfaces via the existing getHeadlessBundle/registerComponentForInit/ initializeWithHeadless pattern (no engine branching). - Composes the ResultList and QuerySummary headless controllers - Renders a 'Load more results' button, a 'Showing X of Y results' summary, and an SLDS progress bar - Announces via aria-live when the last batch of results has been loaded (moreResultsAvailable true -> false within the same search response) - Renders nothing when there are no results - Adds Jest unit tests covering button visibility, click/append behavior, the aria-live transition, empty state, and both engines - Adds a Playwright e2e harness mirroring quantic-pager's structure - Adds new quantic_LoadMoreResults, quantic_ShowingResultsOfLoadMore (+ _plural/_zero), and quantic_AllResultsLoaded labels with FR/ES translations - Adds an example-community wrapper component and route/view entries - Documents the quantic-pager alternative relationship in the component's class-level JSDoc, since Quantic has no separate narrative-doc mechanism outside JSDoc-generated reference docs Refs SFINT-6878 --- ...quantic-add-load-more-results-component.md | 7 + .../exampleQuanticLoadMoreResults.html | 19 + .../exampleQuanticLoadMoreResults.js | 29 ++ .../exampleQuanticLoadMoreResults.js-meta.xml | 12 + .../labels/CustomLabels.labels-meta.xml | 35 ++ .../force-app/main/default/lwc/jsconfig.json | 3 + .../__tests__/quanticLoadMoreResults.test.js | 362 ++++++++++++++++++ .../lwc/quanticLoadMoreResults/e2e/fixture.ts | 79 ++++ .../quanticLoadMoreResults/e2e/pageObject.ts | 41 ++ .../e2e/quanticLoadMoreResults.e2e.ts | 47 +++ .../quanticLoadMoreResults.css | 3 + .../quanticLoadMoreResults.html | 38 ++ .../quanticLoadMoreResults.js | 182 +++++++++ .../quanticLoadMoreResults.js-meta.xml | 5 + .../main/translations/es.translation-meta.xml | 20 + .../main/translations/fr.translation-meta.xml | 20 + .../routes/quanticLoadMoreResults.json | 11 + .../Quantic_Examples1/views/home.json | 2 +- .../views/quanticLoadMoreResults.json | 81 ++++ packages/quantic/tsconfig.json | 3 + 20 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 .changeset/quantic-add-load-more-results-component.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 create mode 100644 packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/routes/quanticLoadMoreResults.json create mode 100644 packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/quanticLoadMoreResults.json diff --git a/.changeset/quantic-add-load-more-results-component.md b/.changeset/quantic-add-load-more-results-component.md new file mode 100644 index 00000000000..e38cd548545 --- /dev/null +++ b/.changeset/quantic-add-load-more-results-component.md @@ -0,0 +1,7 @@ +--- +"@coveo/quantic": minor +--- + +Added the `quantic-load-more-results` component, letting users load additional results into +the current result list without navigating to a new page, for both Search and Insight Panel +interfaces. 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..94fd59acf5b --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.html @@ -0,0 +1,19 @@ + 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..a08af716807 --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js @@ -0,0 +1,29 @@ +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 into the current result list without navigating to a new page.'; + options = [ + { + attribute: 'useCase', + label: 'Use Case', + description: + 'Define which use case to test. Possible values are: search, insight', + defaultValue: 'search', + }, + ]; + + get notConfigured() { + return !this.isConfigured; + } + + 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..7e6ea715794 --- /dev/null +++ b/packages/quantic/force-app/examples/main/lwc/exampleQuanticLoadMoreResults/exampleQuanticLoadMoreResults.js-meta.xml @@ -0,0 +1,12 @@ + + + 52.0 + true + + lightning__RecordPage + lightning__AppPage + lightning__HomePage + 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..5565371ec34 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 @@ -1750,4 +1750,39 @@ false Dislike + + quantic_LoadMoreResults + Load more results + en_US + false + Load more results + + + quantic_ShowingResultsOfLoadMore + Showing {{0}} of {{1}} result + en_US + false + ex: Showing 10 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 + All results have been loaded. + \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/lwc/jsconfig.json b/packages/quantic/force-app/main/default/lwc/jsconfig.json index e03d16d29ae..36c86bd9470 100644 --- a/packages/quantic/force-app/main/default/lwc/jsconfig.json +++ b/packages/quantic/force-app/main/default/lwc/jsconfig.json @@ -26,6 +26,9 @@ "quanticCategoryFacetValue/quanticCategoryFacetValue.js" ], "c/quanticFacet": ["quanticFacet/quanticFacet.js"], + "c/quanticLoadMoreResults": [ + "quanticLoadMoreResults/quanticLoadMoreResults.js" + ], "c/quanticPager": ["quanticPager/quanticPager.js"], "c/quanticPlaceholder": ["quanticPlaceholder/quanticPlaceholder.js"], "c/quanticQueryError": ["quanticQueryError/quanticQueryError.js"], 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..ca32da00ce5 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/__tests__/quanticLoadMoreResults.test.js @@ -0,0 +1,362 @@ +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'; + +jest.mock( + '@salesforce/label/c.quantic_LoadMoreResults', + () => ({default: 'Load more results'}), + {virtual: true} +); +jest.mock( + '@salesforce/label/c.quantic_ShowingResultsOfLoadMore', + () => ({default: 'Showing {{0}} of {{1}} result'}), + {virtual: true} +); +jest.mock( + '@salesforce/label/c.quantic_ShowingResultsOfLoadMore_plural', + () => ({default: 'Showing {{0}} of {{1}} results'}), + {virtual: true} +); +jest.mock( + '@salesforce/label/c.quantic_ShowingResultsOfLoadMore_zero', + () => ({default: 'No results'}), + {virtual: true} +); +jest.mock( + '@salesforce/label/c.quantic_AllResultsLoaded', + () => ({default: 'All results have been loaded.'}), + {virtual: true} +); + +const headlessLoaderMock = jest.mocked(quanticHeadlessLoader); +const engineMock = { + id: 'mockEngine', + dispatch: jest.fn(), +}; + +const initialResultListState = { + hasResults: true, + moreResultsAvailable: true, + results: [{title: 'result 1'}, {title: 'result 2'}], + searchResponseId: 'response-1', +}; +let resultListState = {...initialResultListState}; + +const initialQuerySummaryState = { + firstResult: 1, + lastResult: 10, + total: 123, + hasResults: true, +}; +let querySummaryState = {...initialQuerySummaryState}; + +const functionsMocks = { + fetchMoreResults: jest.fn(() => {}), + subscribeResultList: jest.fn((cb) => { + cb(); + return functionsMocks.unsubscribeResultList; + }), + unsubscribeResultList: jest.fn(() => {}), + subscribeQuerySummary: jest.fn((cb) => { + cb(); + return functionsMocks.unsubscribeQuerySummary; + }), + unsubscribeQuerySummary: jest.fn(() => {}), + dispatchMessage: jest.fn(() => {}), +}; + +const resultListControllerMock = { + fetchMoreResults: functionsMocks.fetchMoreResults, + get state() { + return resultListState; + }, + subscribe: functionsMocks.subscribeResultList, +}; + +const querySummaryControllerMock = { + get state() { + return querySummaryState; + }, + subscribe: functionsMocks.subscribeQuerySummary, +}; + +const buildResultListMock = jest.fn().mockReturnValue(resultListControllerMock); +const buildQuerySummaryMock = jest + .fn() + .mockReturnValue(querySummaryControllerMock); + +const headlessMock = { + buildResultList: buildResultListMock, + buildQuerySummary: buildQuerySummaryMock, +}; +headlessLoaderMock.getHeadlessBundle.mockReturnValue(headlessMock); + +let isInitialized = false; +headlessLoaderMock.initializeWithHeadless.mockImplementation( + async (element, _, initialize) => { + if (element instanceof QuanticLoadMoreResults && !isInitialized) { + isInitialized = true; + initialize(engineMock); + } + } +); + +const quanticUtilsMock = jest.mocked(quanticUtils); +quanticUtilsMock.AriaLiveRegion.mockReturnValue({ + dispatchMessage: functionsMocks.dispatchMessage, + registerRegion: undefined, +}); +quanticUtilsMock.I18nUtils.getLabelNameWithCount.mockImplementation( + (labelName, count) => { + if (count === 0) { + return `${labelName}_zero`; + } + if (count !== 1) { + return `${labelName}_plural`; + } + return labelName; + } +); +quanticUtilsMock.I18nUtils.format.mockImplementation((str, ...args) => + str.replace(/{{(\d+)}}/g, (_match, index) => args[index]) +); + +const selectors = { + loadMoreButton: '[data-testid="load-more-results-button"]', + summary: '[data-testid="summary"]', + progressBar: '[data-testid="progress-bar"]', + componentError: 'c-quantic-component-error', +}; + +const createTestComponent = buildCreateTestComponent( + QuanticLoadMoreResults, + 'c-quantic-load-more-results', + {engineId: engineMock.id} +); + +describe('c-quantic-load-more-results', () => { + afterEach(() => { + cleanup(); + isInitialized = false; + resultListState = {...initialResultListState}; + querySummaryState = {...initialQuerySummaryState}; + buildResultListMock.mockClear(); + buildQuerySummaryMock.mockClear(); + functionsMocks.fetchMoreResults.mockClear(); + functionsMocks.subscribeResultList.mockClear(); + functionsMocks.unsubscribeResultList.mockClear(); + functionsMocks.subscribeQuerySummary.mockClear(); + functionsMocks.unsubscribeQuerySummary.mockClear(); + functionsMocks.dispatchMessage.mockClear(); + }); + + describe('controller initialization', () => { + it('should build the necessary controllers and subscribe to the headless state changes', async () => { + createTestComponent(); + await flushPromises(); + + expect(buildResultListMock).toHaveBeenCalledTimes(1); + expect(buildResultListMock).toHaveBeenCalledWith(engineMock); + expect(functionsMocks.subscribeResultList).toHaveBeenCalledTimes(1); + expect(buildQuerySummaryMock).toHaveBeenCalledTimes(1); + expect(buildQuerySummaryMock).toHaveBeenCalledWith(engineMock); + expect(functionsMocks.subscribeQuerySummary).toHaveBeenCalledTimes(1); + }); + }); + + describe('when hasResults is true', () => { + it('should render the summary and the progress bar', async () => { + const element = createTestComponent(); + await flushPromises(); + + expect( + element.shadowRoot.querySelector(selectors.summary) + ).not.toBeNull(); + expect( + element.shadowRoot.querySelector(selectors.progressBar) + ).not.toBeNull(); + }); + + describe('#load more button visibility (AC4)', () => { + it('should render the button when moreResultsAvailable is true', async () => { + resultListState = {...resultListState, moreResultsAvailable: true}; + const element = createTestComponent(); + await flushPromises(); + + expect( + element.shadowRoot.querySelector(selectors.loadMoreButton) + ).not.toBeNull(); + }); + + it('should not render the button when moreResultsAvailable is false', async () => { + resultListState = {...resultListState, moreResultsAvailable: false}; + const element = createTestComponent(); + await flushPromises(); + + expect( + element.shadowRoot.querySelector(selectors.loadMoreButton) + ).toBeNull(); + }); + }); + + describe('#click behavior (AC3)', () => { + it('should call fetchMoreResults and keep prior results referenced in state when clicked', async () => { + resultListState = {...resultListState, moreResultsAvailable: true}; + const element = createTestComponent(); + await flushPromises(); + + const button = element.shadowRoot.querySelector( + selectors.loadMoreButton + ); + expect(button).not.toBeNull(); + button.click(); + + expect(functionsMocks.fetchMoreResults).toHaveBeenCalledTimes(1); + expect(resultListState.results).toEqual(initialResultListState.results); + }); + }); + + describe('#summary and progress values (AC1)', () => { + it('should reflect the querySummary state', async () => { + querySummaryState = {...querySummaryState, lastResult: 10, total: 123}; + const element = createTestComponent(); + await flushPromises(); + + const summary = element.shadowRoot.querySelector(selectors.summary); + expect(summary.textContent).toContain('10'); + expect(summary.textContent).toContain('123'); + }); + }); + + describe('#aria-live announcement (AC5)', () => { + it('should dispatch a message when moreResultsAvailable transitions from true to false within the same search response', async () => { + resultListState = { + ...initialResultListState, + moreResultsAvailable: true, + searchResponseId: 'response-1', + }; + createTestComponent(); + await flushPromises(); + + resultListState = { + ...resultListState, + moreResultsAvailable: false, + searchResponseId: 'response-1', + }; + functionsMocks.subscribeResultList.mock.calls[0][0](); + await flushPromises(); + + expect(functionsMocks.dispatchMessage).toHaveBeenCalledWith( + 'All results have been loaded.' + ); + }); + + it('should not dispatch a message when a new search returns moreResultsAvailable false with a different searchResponseId', async () => { + resultListState = { + ...initialResultListState, + moreResultsAvailable: false, + searchResponseId: 'response-1', + }; + createTestComponent(); + await flushPromises(); + + expect(functionsMocks.dispatchMessage).not.toHaveBeenCalled(); + }); + + it('should not dispatch a message on an unrelated re-render where moreResultsAvailable stays false', async () => { + resultListState = { + ...initialResultListState, + moreResultsAvailable: false, + searchResponseId: 'response-1', + }; + createTestComponent(); + await flushPromises(); + + functionsMocks.subscribeResultList.mock.calls[0][0](); + await flushPromises(); + + expect(functionsMocks.dispatchMessage).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when hasResults is false (AC6)', () => { + it('should not render anything', async () => { + resultListState = {...resultListState, hasResults: false}; + const element = createTestComponent(); + await flushPromises(); + + expect(element.shadowRoot.querySelector(selectors.summary)).toBeNull(); + expect( + element.shadowRoot.querySelector(selectors.progressBar) + ).toBeNull(); + expect( + element.shadowRoot.querySelector(selectors.loadMoreButton) + ).toBeNull(); + }); + }); + + describe('disconnectedCallback', () => { + it('should unsubscribe from resultList and querySummary when component is disconnected', async () => { + const element = createTestComponent(); + await flushPromises(); + + element.remove(); + + expect(functionsMocks.unsubscribeResultList).toHaveBeenCalledTimes(1); + expect(functionsMocks.unsubscribeQuerySummary).toHaveBeenCalledTimes(1); + }); + }); + + describe('when there is an initialization error', () => { + it('should display the error component', async () => { + headlessLoaderMock.initializeWithHeadless.mockImplementationOnce( + (element) => { + if (element instanceof QuanticLoadMoreResults) { + element.setInitializationError(); + } + } + ); + const element = createTestComponent(); + await flushPromises(); + + expect( + element.shadowRoot.querySelector(selectors.componentError) + ).not.toBeNull(); + }); + }); + + describe('both engines (AC2, AC7)', () => { + it('should build the same controllers when the headless bundle is the insight bundle', async () => { + headlessLoaderMock.getHeadlessBundle.mockReturnValueOnce({ + ...headlessMock, + insightSpecificFlag: true, + }); + const element = createTestComponent(); + await flushPromises(); + + expect(buildResultListMock).toHaveBeenCalledTimes(1); + expect(buildResultListMock).toHaveBeenCalledWith(engineMock); + expect(buildQuerySummaryMock).toHaveBeenCalledTimes(1); + expect(buildQuerySummaryMock).toHaveBeenCalledWith(engineMock); + expect( + element.shadowRoot.querySelector(selectors.loadMoreButton) + ).not.toBeNull(); + }); + + it('should build the same controllers when the headless bundle is the search bundle', async () => { + const element = createTestComponent(); + await flushPromises(); + + expect(buildResultListMock).toHaveBeenCalledTimes(1); + expect(buildQuerySummaryMock).toHaveBeenCalledTimes(1); + expect( + element.shadowRoot.querySelector(selectors.loadMoreButton) + ).not.toBeNull(); + }); + }); +}); 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..886f5ee06a6 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/fixture.ts @@ -0,0 +1,79 @@ +import {LoadMoreResultsObject} from './pageObject'; +import {quanticBase} from '../../../../../../playwright/fixtures/baseFixture'; +import {SearchObject} from '../../../../../../playwright/page-object/searchObject'; +import { + searchRequestRegex, + insightSearchRequestRegex, +} from '../../../../../../playwright/utils/requests'; +import {InsightSetupObject} from '../../../../../../playwright/page-object/insightSetupObject'; +import {useCaseEnum} from '../../../../../../playwright/utils/useCase'; + +const pageUrl = 's/quantic-load-more-results'; + +type QuanticLoadMoreResultsE2EFixtures = { + loadMoreResults: LoadMoreResultsObject; + search: SearchObject; + emptyResults: boolean; +}; + +type QuanticLoadMoreResultsE2ESearchFixtures = + QuanticLoadMoreResultsE2EFixtures; + +type QuanticLoadMoreResultsE2EInsightFixtures = + QuanticLoadMoreResultsE2ESearchFixtures & { + insightSetup: InsightSetupObject; + }; + +export const testSearch = + quanticBase.extend({ + emptyResults: false, + search: async ({page}, use) => { + await use(new SearchObject(page, searchRequestRegex)); + }, + loadMoreResults: async ( + {page, configuration, search, emptyResults}, + use + ) => { + await search.mockSearchWithBaseResponse(); + if (emptyResults) { + await search.mockEmptySearchResponse(); + } + await page.goto(pageUrl); + const searchResponsePromise = search.waitForSearchResponse(); + configuration.configure({}); + await searchResponsePromise; + + await use(new LoadMoreResultsObject(page)); + }, + }); + +export const testInsight = + quanticBase.extend({ + emptyResults: false, + 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, emptyResults}, + use + ) => { + await search.mockSearchWithBaseResponse(); + if (emptyResults) { + await search.mockEmptySearchResponse(); + } + await page.goto(pageUrl); + configuration.configure({useCase: useCaseEnum.insight}); + await insightSetup.waitForInsightInterfaceInitialization(); + await Promise.all([ + search.waitForSearchResponse(), + search.performSearch(), + ]); + + await use(new LoadMoreResultsObject(page)); + }, + }); + +export {expect} from '@playwright/test'; 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..3d7ccf149cb --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/pageObject.ts @@ -0,0 +1,41 @@ +import type {Locator, Page} from '@playwright/test'; + +const loadMoreResultsElementsSelectors = { + component: 'c-quantic-load-more-results', + componentError: 'c-quantic-component-error', + loadMoreButtonTestId: 'load-more-results-button', + summaryTestId: 'summary', + progressBarTestId: 'progress-bar', +}; + +export class LoadMoreResultsObject { + constructor(public page: Page) { + this.page = page; + } + + get component(): Locator { + return this.page.locator(loadMoreResultsElementsSelectors.component); + } + + get loadMoreButton(): Locator { + return this.page.getByTestId( + loadMoreResultsElementsSelectors.loadMoreButtonTestId + ); + } + + get summary(): Locator { + return this.page.getByTestId( + loadMoreResultsElementsSelectors.summaryTestId + ); + } + + get progressBar(): Locator { + return this.page.getByTestId( + loadMoreResultsElementsSelectors.progressBarTestId + ); + } + + 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..b6e843e48d4 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/e2e/quanticLoadMoreResults.e2e.ts @@ -0,0 +1,47 @@ +import {testSearch, testInsight, expect} from './fixture'; +import {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.describe('when more results are available', () => { + test('should render the button, summary, and progress indicator', async ({ + loadMoreResults, + }) => { + await expect(loadMoreResults.summary).toBeVisible(); + await expect(loadMoreResults.progressBar).toBeVisible(); + await expect(loadMoreResults.loadMoreButton).toBeVisible(); + }); + + test.describe('when clicking the load more button', () => { + test('should append the next batch of results and update the summary', async ({ + loadMoreResults, + search, + }) => { + const searchResponsePromise = search.waitForSearchRequest(); + await loadMoreResults.clickLoadMoreButton(); + const searchRequest = await searchResponsePromise; + const {firstResult} = searchRequest.postDataJSON(); + + expect(firstResult).toBeGreaterThan(0); + }); + }); + }); + + test.describe('when no results are available', () => { + test.use({emptyResults: true}); + + test('should render nothing', async ({loadMoreResults}) => { + await expect(loadMoreResults.summary).toBeHidden(); + await expect(loadMoreResults.progressBar).toBeHidden(); + await expect(loadMoreResults.loadMoreButton).toBeHidden(); + }); + }); + }); +}); 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..20ad6b1c00c --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css @@ -0,0 +1,3 @@ +.slds-progress-bar__value { + width: 0%; +} 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..a690ec5d153 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.html @@ -0,0 +1,38 @@ + 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..8863dccfd94 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js @@ -0,0 +1,182 @@ +import LOCALE from '@salesforce/i18n/locale'; +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 end user to load additional results into the + * current result list without navigating to a new page. + * + * Use `quantic-load-more-results` as an alternative to `quantic-pager` for letting users see more + * results — do not combine the two on the same page. `quantic-pager` provides traditional + * page-based navigation, while `quantic-load-more-results` incrementally appends results to the + * existing list. + * @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 {ResultListState} */ + previousResultListState; + /** @type {AnyHeadless} */ + headless; + /** @type {import('c/quanticUtils').AriaLiveUtils} */ + allResultsLoadedAriaMessage; + /** @type {boolean} */ + hasInitializationError = false; + + 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('loadMoreResults', 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 + ); + } + } + + fetchMoreResults() { + this.resultList.fetchMoreResults(); + } + + get hasResults() { + return !!this.resultListState?.hasResults; + } + + get moreResultsAvailable() { + return !!this.resultListState?.moreResultsAvailable; + } + + get total() { + return Intl.NumberFormat(LOCALE) + .format(this.querySummaryState?.total) + .toString(); + } + + get lastResult() { + return Intl.NumberFormat(LOCALE) + .format(this.querySummaryState?.lastResult) + .toString(); + } + + get summaryLabel() { + const labelName = I18nUtils.getLabelNameWithCount( + 'showingResultsOfLoadMore', + this.querySummaryState?.lastResult + ); + return I18nUtils.format( + this.labels[labelName], + this.lastResult, + this.total + ); + } + + /** + * @returns {number} + */ + get progressPercentage() { + const total = this.querySummaryState?.total; + if (!total) { + return 0; + } + return Math.min(100, (this.querySummaryState?.lastResult / total) * 100); + } + + get progressBarStyle() { + return `width: ${this.progressPercentage}%;`; + } + + /** + * 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..9176009d178 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.js-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + false + 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..b7406310e7b 100644 --- a/packages/quantic/force-app/main/translations/es.translation-meta.xml +++ b/packages/quantic/force-app/main/translations/es.translation-meta.xml @@ -960,4 +960,24 @@ quantic_Dislike + + + quantic_LoadMoreResults + + + + quantic_ShowingResultsOfLoadMore + + + + quantic_ShowingResultsOfLoadMore_plural + + + + quantic_ShowingResultsOfLoadMore_zero + + + + quantic_AllResultsLoaded + \ No newline at end of file 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..504078cebb2 100644 --- a/packages/quantic/force-app/main/translations/fr.translation-meta.xml +++ b/packages/quantic/force-app/main/translations/fr.translation-meta.xml @@ -964,4 +964,24 @@ quantic_Dislike + + + quantic_LoadMoreResults + + + + quantic_ShowingResultsOfLoadMore + + + + quantic_ShowingResultsOfLoadMore_plural + + + + quantic_ShowingResultsOfLoadMore_zero + + + + quantic_AllResultsLoaded + \ No newline at end of file diff --git a/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/routes/quanticLoadMoreResults.json b/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/routes/quanticLoadMoreResults.json new file mode 100644 index 00000000000..0fac8a01788 --- /dev/null +++ b/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/routes/quanticLoadMoreResults.json @@ -0,0 +1,11 @@ +{ + "activeViewId": "31e3f479-f79a-485a-99bc-4ce8b6c52dac", + "appPageId": "2199ef25-89a9-44a1-ad69-7cb8bd918245", + "configurationTags": [], + "id": "7894926a-e484-42cf-9f58-8f78042de3c0", + "label": "Quantic Load More Results", + "pageAccess": "UseParent", + "routeType": "custom-quantic-load-more-results", + "type": "route", + "urlPrefix": "quantic-load-more-results" +} diff --git a/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/home.json b/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/home.json index 90b96fdd6b9..d1fd48dbf48 100644 --- a/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/home.json +++ b/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/home.json @@ -14,7 +14,7 @@ "components": [ { "componentAttributes": { - "richTextValue": "

Quantic UI Showcase

Explore how to use and customize the Quantic framework through real-world examples and a live component playground.



Solution Examples 🧩

Real-world reference implementations built with Quantic to showcase various use cases




Quantic Components Playground 🧪

An environment for testing and experimenting with all available Quantic components in isolation.


" + "richTextValue": "

Quantic UI Showcase

Explore how to use and customize the Quantic framework through real-world examples and a live component playground.



Solution Examples 🧩

Real-world reference implementations built with Quantic to showcase various use cases




Quantic Components Playground 🧪

An environment for testing and experimenting with all available Quantic components in isolation.


" }, "componentName": "forceCommunity:richTextInline", "id": "1c5c28a3-9570-4c6c-abf9-52ad1b26582a", diff --git a/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/quanticLoadMoreResults.json b/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/quanticLoadMoreResults.json new file mode 100644 index 00000000000..4248da0258e --- /dev/null +++ b/packages/quantic/quantic-examples-community/experiences/Quantic_Examples1/views/quanticLoadMoreResults.json @@ -0,0 +1,81 @@ +{ + "appPageId": "2199ef25-89a9-44a1-ad69-7cb8bd918245", + "componentName": "siteforce:dynamicLayout", + "dataProviders": [], + "id": "31e3f479-f79a-485a-99bc-4ce8b6c52dac", + "label": "Quantic Load More Results", + "regions": [ + { + "components": [ + { + "componentAttributes": { + "background": "background: rgba(0,0,0,0)", + "backgroundOverlay": "rgba(0,0,0,0.5)", + "contentAreaWidth": 100, + "sectionConfig": { + "UUID": "914f7651-c6df-4df8-b138-d0cdbc6accea", + "columns": [ + { + "UUID": "0ffd6f5e-ce66-498a-8788-54d9eb81c4df", + "columnKey": "1", + "columnName": "Column 1", + "columnWidth": "12", + "seedComponents": [] + } + ] + }, + "sectionHeight": 300 + }, + "componentName": "forceCommunity:section", + "id": "914f7651-c6df-4df8-b138-d0cdbc6accea", + "regions": [ + { + "components": [ + { + "componentAttributes": {}, + "componentName": "c:exampleQuanticLoadMoreResults", + "id": "813de14a-37a6-4272-a472-1fec240c77a8", + "renderPriority": "NEUTRAL", + "renditionMap": {}, + "type": "component" + } + ], + "id": "0ffd6f5e-ce66-498a-8788-54d9eb81c4df", + "regionLabel": "Column 1", + "regionName": "1", + "renditionMap": {}, + "type": "region" + } + ], + "renderPriority": "NEUTRAL", + "renditionMap": {}, + "type": "component" + } + ], + "id": "91d526f8-0188-4a60-b010-1018a3d64d0e", + "regionName": "content", + "type": "region" + }, + { + "components": [ + { + "componentAttributes": { + "customHeadTags": "", + "description": "", + "title": "Quantic Load More Results" + }, + "componentName": "forceCommunity:seoAssistant", + "id": "f4d63a94-0c97-4575-8cd8-06b3f82ddb9c", + "renditionMap": {}, + "type": "component" + } + ], + "id": "31e3f479-f79a-485a-99bc-4ce8b6c52dad", + "regionName": "sfdcHiddenRegion", + "type": "region" + } + ], + "themeLayoutType": "Inner", + "type": "view", + "viewType": "custom-quantic-load-more-results" +} diff --git a/packages/quantic/tsconfig.json b/packages/quantic/tsconfig.json index a4e7d0754f3..749160e7f60 100644 --- a/packages/quantic/tsconfig.json +++ b/packages/quantic/tsconfig.json @@ -35,6 +35,9 @@ "quanticCategoryFacetValue/quanticCategoryFacetValue.js" ], "c/quanticFacet": ["quanticFacet/quanticFacet.js"], + "c/quanticLoadMoreResults": [ + "quanticLoadMoreResults/quanticLoadMoreResults.js" + ], "c/quanticPager": ["quanticPager/quanticPager.js"], "c/quanticPlaceholder": ["quanticPlaceholder/quanticPlaceholder.js"], "c/quanticQueryError": ["quanticQueryError/quanticQueryError.js"], From 9f2649d7f78b224f9c5277bcec9b9a472371f5bc Mon Sep 17 00:00:00 2001 From: mmitiche Date: Fri, 31 Jul 2026 00:30:03 -0400 Subject: [PATCH 3/3] test(quantic): exercise append behavior in load-more-results test Extend the click test for quantic-load-more-results to simulate a second subscribe callback invocation with an appended results array, and assert the rendered summary/progress reflect the new count. Also remove the dead .slds-progress-bar__value CSS rule, which is fully superseded by the inline style binding driven by progressBarStyle. Addresses non-blocking findings from PR review (S7). --- .../__tests__/quanticLoadMoreResults.test.js | 32 +++++++++++++++++-- .../quanticLoadMoreResults.css | 3 -- 2 files changed, 30 insertions(+), 5 deletions(-) delete mode 100644 packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css 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 index ca32da00ce5..5beaeb17725 100644 --- 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 @@ -204,8 +204,9 @@ describe('c-quantic-load-more-results', () => { }); describe('#click behavior (AC3)', () => { - it('should call fetchMoreResults and keep prior results referenced in state when clicked', async () => { + it('should call fetchMoreResults and append newly fetched results without discarding prior ones when clicked', async () => { resultListState = {...resultListState, moreResultsAvailable: true}; + querySummaryState = {...querySummaryState, lastResult: 2, total: 6}; const element = createTestComponent(); await flushPromises(); @@ -216,7 +217,34 @@ describe('c-quantic-load-more-results', () => { button.click(); expect(functionsMocks.fetchMoreResults).toHaveBeenCalledTimes(1); - expect(resultListState.results).toEqual(initialResultListState.results); + + const appendedResults = [ + ...initialResultListState.results, + {title: 'result 3'}, + {title: 'result 4'}, + {title: 'result 5'}, + {title: 'result 6'}, + ]; + resultListState = { + ...resultListState, + results: appendedResults, + moreResultsAvailable: false, + }; + querySummaryState = {...querySummaryState, lastResult: 6, total: 6}; + functionsMocks.subscribeResultList.mock.calls[0][0](); + functionsMocks.subscribeQuerySummary.mock.calls[0][0](); + await flushPromises(); + + expect(resultListState.results).toEqual(appendedResults); + expect(resultListState.results).toHaveLength(6); + + const summary = element.shadowRoot.querySelector(selectors.summary); + expect(summary.textContent).toContain('6'); + + const progressBar = element.shadowRoot.querySelector( + selectors.progressBar + ); + expect(progressBar.getAttribute('aria-valuenow')).toBe('100'); }); }); diff --git a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css b/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css deleted file mode 100644 index 20ad6b1c00c..00000000000 --- a/packages/quantic/force-app/main/default/lwc/quanticLoadMoreResults/quanticLoadMoreResults.css +++ /dev/null @@ -1,3 +0,0 @@ -.slds-progress-bar__value { - width: 0%; -}