Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quantic-load-more-results.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<template>
<c-example-layout
title={pageTitle}
description={pageDescription}
show-preview={isConfigured}>

<div slot="configuration">
<c-configurator options={options} ontryitnow={handleTryItNow}>
<c-action-perform-search slot="actions" engine-id={engineId}></c-action-perform-search>
</c-configurator>
</div>

<c-example-use-case slot="preview" use-case={config.useCase} engine-id={engineId}>
<c-quantic-result-list engine-id={engineId}></c-quantic-result-list>
<c-quantic-load-more-results engine-id={engineId}></c-quantic-load-more-results>
</c-example-use-case>
</c-example-layout>
</template>
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>64.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
</LightningComponentBundle>
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@
<protected>false</protected>
<shortDescription>No Results for potato</shortDescription>
</labels>
<labels>
<fullName>quantic_LoadMoreResults</fullName>
<value>Load more results</value>
<language>en_US</language>
<protected>false</protected>
<shortDescription>Label of the load more results button</shortDescription>
</labels>
<labels>
<fullName>quantic_ShowingResultsOfLoadMore</fullName>
<value>Showing {{0}} of {{1}} result</value>
<language>en_US</language>
<protected>false</protected>
<shortDescription>ex: Showing 1 of 123 result</shortDescription>
</labels>
<labels>
<fullName>quantic_ShowingResultsOfLoadMore_plural</fullName>
<value>Showing {{0}} of {{1}} results</value>
<language>en_US</language>
<protected>false</protected>
<shortDescription>ex: Showing 10 of 123 results</shortDescription>
</labels>
<labels>
<fullName>quantic_AllResultsLoaded</fullName>
<value>All results have been loaded</value>
<language>en_US</language>
<protected>false</protected>
<shortDescription>Aria-live announcement when the last batch of results has been loaded</shortDescription>
</labels>
<labels>
<fullName>quantic_Clear</fullName>
<value>Clear</value>
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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<QuanticLoadMoreResultsE2EFixtures>({
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<QuanticLoadMoreResultsE2EInsightFixtures>({
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));
},
});
Loading
Loading