Read more at jamessimone.net, or return to the homepage





Lightning Web Components Composable Pagination

Let's talk about pagination — one of the common challenges in frontend development, particularly in the mobile-first world of consumer-facing web development, is in reducing the amount of vertical scrolling that your target audience is responsible for. As well, complicated DOM trees and long lists of elements being rendered tends to slow down browsers. Pagination solves both of these concerns by conditionally rendering elements on-screen, hiding the rest until the next page is requested by the user.

In this article, you'll learn how to implement pagination properly in LWC, and unlock the potential of composable Lightning Web Components in the process. Composition over inheritance is one of the most crucial concepts in Object-Oriented Programming, but the Lightning Web Components documentation doesn't give newer developers enough in the way of resources when it comes to building a complicated, reusable component — exactly what we'd like to do when implementing pagination. Indeed, the "paginator" component shown on the Trailhead LWC github is so barebones that I feel bad imagining somebody tasked with implementing a paginating component using that as a starting point.

Let's get started:

linkSlot-Based Composition

In order to understand how the Web Components framework — which Salesforce has embraced with Lightning Web Components — enables the use of composition to create larger components from reusable component "blocks", it's important to review the basics of the <slot></slot>-based system. Consider the following LWC HTML markup:

lwc/title-component/title-component.html
1link<template>

2link <section>

3link <h1>{title}<h1>

4link <slot></slot>

5link </section>

6link</template>

Paired with your garden-variety JS:

lwc/title-component/title-component.js
1linkimport { api, LightningElement } from "lwc";

2link

3linkexport default class TitleComponent extends LightningElement {

4link @api title;

5link}

Now, in another LWC, you would add the remainder of your HTML-markup while making use of the header, and the <slot> is replaced by that markup:

1link<template>

2link <c-titlecomponent title="Hello World">

3link <div>

4link <!-- the contents here would replace the <slot> -->

5link </div>

6link </c-titlecomponent>

7link</template>

In short, slot-based composition allows us to access the public properties/methods of components to inject the HTML that we want without duplicating the same markup everywhere. By itself, this example is a little too abstract — let's begin with a more concrete example. You might remember from the React Versus Lightning Web Components article that there I introduced a simple "FAQ" component to compare between the two web frameworks. If you haven't read it, or as a reminder, here's what the example FAQ component ends up looking like:

lwc/faq/faq.html
1link<template>

2link <template if:true="{faqs.data}">

3link <h1

4link class="slds-text-heading_large slds-align_absolute-center slds-m-top_small"

5link >

6link {title}

7link </h1>

8link <template for:each="{faqs.data}" for:item="faq">

9link <lightning-accordion allow-multiple-sections-open key="{faq.key}">

10link <lightning-accordion-section name="{faq.key}" label="{faq.question}">

11link <small>{faq.answer}</small>

12link </lightning-accordion-section>

13link </lightning-accordion>

14link </template>

15link </template>

16link</template>

And the JS:

lwc/faq/faq.js
1linkimport { api, LightningElement, wire } from "lwc";

2link//just a stub method, returns 100 FAQs

3linkimport getFAQs from "@salesforce/apex/FAQController.getFAQs";

4link

5linkexport default class FAQList extends LightningElement {

6link @api title = "FAQs";

7link @wire(getFAQs) faqs;

8link}

Lightning Web Components FAQ example

You could imagine this component being used in a Salesforce Community page — there's just one problem. In the example, the FAQ is populated with a hard-coded list of 100 frequently asked questions. While that may be going a bit overboard, the fact remains that the FAQ component quickly grows too big to display on anything other than a tabbed flexipage; it dominates other components in the same view.

linkStarting To Paginate

Let's introduce the concept of a wrapper pagination component using the power of <slot>s to begin reducing the viewport size of the FAQ component. We'll also move the title property over to this wrapper. In the beginning, it's going to need a clearly defined viewable section, and buttons to move through the current list of pages. We'll walk through setting up the dynamically generated list of pages later:

lwc/pager/pager.html
1link<template>

2link <section>

3link <div class="page-data-container">

4link <h1

5link class="slds-text-heading_large slds-align_absolute-center slds-m-top_small"

6link >

7link {title}

8link </h1>

9link <!-- the crucial slot -->

10link <slot></slot>

11link </div>

12link <div class="page-data-container">

13link <lightning-button-icon

14link alternative-text="Previous"

15link icon-class="slds-m-around_medium"

16link icon-name="utility:chevronleft"

17link variant="bare"

18link >

19link </lightning-button-icon>

20link <lightning-button-icon

21link alternative-text="Next"

22link class="slds-float_right"

23link icon-class="slds-m-around_medium"

24link icon-name="utility:chevronright"

25link variant="bare"

26link >

27link </lightning-button-icon>

28link </div>

29link </section>

30link</template>

The JS:

lwc/pager/pager.js
1linkimport { api, LightningElement, track } from "lwc";

2link

3linkexport default class Pager extends LightningElement {

4link @api pagedata = [];

5link @api title = "";

6link @track currentPageIndex = 0;

7link @track maxNumberOfPages = 0;

8link //later, we might make this a configurable property

9link //hard-coding for now

10link MAX_PAGES_TO_SHOW = 5;

11link

12link renderedCallback() {

13link this.maxNumberOfPages = !!this.pagedata ? this.pagedata.length : 0;

14link }

15link}

And the CSS:

lwc/pager/pager.css
1link:host {

2link --white: rgb(255, 255, 255);

3link}

4link

5link.page-data-container {

6link background-color: var(--white);

7link max-height: 400px;

8link overflow: hidden;

9link}

Now all we need to do is update our FAQ component to pass the data into the pager:

lwc/faq/faq.html
1link<template>

2link <template if:true="{faqs.data}">

3link <c-pager pagedata="{faqs.data}" title="FAQ">

4link <template for:each="{faqs.data}" for:item="faq">

5link <lightning-accordion allow-multiple-sections-open key="{faq.key}">

6link <lightning-accordion-section name="{faq.key}" label="{faq.question}">

7link <small>{faq.answer}</small>

8link </lightning-accordion-section>

9link </lightning-accordion>

10link </template>

11link </c-pager>

12link </template>

13link</template>

That gives us this:

Early version of the pager

There's only one problem (well, there's a lot of problems, actually, but let's take it piece-by-piece): all 100 of the FAQs are actually on the page at the moment; they're just hidden due to the overflow: hidden CSS property being applied. At the moment, there exists a crucial disconnect between the data being passed in by the @track Apex API call and the output of the component; the pager doesn't receive the rendered markdown in its pagedata property, but rather the raw data. Because we want this component to be generic, we have to prevent the pager from knowing about the internals of the markdown that will be produced. This means that we don't need the pager to know that (in this case) a big list of <lightning-accordion-section>s will be part of the output; what we do need is to have the pager act as the middleman between the data being returned by Apex and the data that the component will use.

linkStarting To Actually Paginate

In order to do so, we'll need to slightly re-work the pager's JavaScript controller:

lwc/pager/pager.js
1link@api

2linkget currentlyShown() {

3link return this.pagedata.slice(

4link this.MAX_PAGES_TO_SHOW * this.currentPageIndex,

5link this.MAX_PAGES_TO_SHOW

6link );

7link}

8link//...

Oh baby. Now we're cooking with gas! Luckily, Array.prototype.slice actually handles sensibly the edge cases for overflowing the array; we don't need to worry about the second argument being greater than the length of the array — if it is, slice will just return all of the elements up till the end of the list. There are some pagination-specific edge cases that will need to be tweaked on this property-getter, but this initial logic will do for the moment — we've got bigger fish to fry! Our child components will need to hook into this publicly-exposed method in order to drive their for:each HTML template directive, making it necessary to update the contents of the FAQ component:

lwc/faq/faq.html
1link<template>

2link <template if:true="{faqs.data}">

3link <c-pager class="pager" pagedata="{faqs.data}" title="FAQ">

4link <template for:each="{currentlyVisible}" for:item="faq">

5link <!-- etc ... -->

6link </template></c-pager

7link ></template

8link ></template

9link>

linkAn Aside On LWC Lifecycle Hooks

Note that the for:each template directive is being driven by a new property, currentlyVisible. Let's step into the FAQ controller to see the rest:

lwc/faq/faq.js
1linkimport { api, LightningElement, wire } from "lwc";

2linkimport getFAQs from "@salesforce/apex/FAQController.getFAQs";

3link

4linkexport default class FAQList extends LightningElement {

5link @wire(getFAQs) faqs;

6link

7link @api currentlyVisible = [];

8link

9link renderedCallback() {

10link this.currentlyVisible = this.template.querySelector(

11link "c-pager"

12link ).currentlyShown;

13link }

14link}

This exposes our first real hurdle in building the pager. The renderedCallback lifecycle method is supposed to be run on the parent component (the FAQ) when the child component (the pager) has finished rendering. Maybe there's an issue with the rendering lifecycle when it comes to slot-based components. Maybe the Salesforce documentation on lifecycle hooks is out of date. Maybe there's something else going on that a better developer than your narrator might be able to pinpoint (and shame on me, really, for assuming that the docs would lead the way). Whatever the case, the call to this.template.querySelector("c-pager") is hopelessly null during the renderedCallback lifecycle method.

To get around this, we'll have to fire a custom event from the pager that components making use of the pager will subscribe to; we also need to expose the currently visible elements as an array:

lwc/pager/pager.js
1link @api

2link get currentlyShown() {

3link//just your run-of-the-mill pagination edge cases

4link const potentialPageStartingRange =

5link this.MAX_PAGES_TO_SHOW * this.currentPageIndex >= this.pagedata.length

6link ? this.pagedata.length - this.MAX_PAGES_TO_SHOW

7link : this.MAX_PAGES_TO_SHOW * this.currentPageIndex;

8link const potentialPageEndingRange =

9link this.currentPageIndex === 0

10link ? this.MAX_PAGES_TO_SHOW

11link : potentialPageStartingRange + this.MAX_PAGES_TO_SHOW;

12link

13link return this.pagedata.slice(

14link potentialPageStartingRange,

15link potentialPageEndingRange

16link );

17link }

18link

19link renderedCallback() {

20link this.maxNumberOfPages = !!this.pagedata

21link ? this.pagedata.length / this.MAX_PAGES_TO_SHOW

22link : 0;

23link this.dispatchEvent(new CustomEvent("pagerchanged"));

24link }

And then in the FAQ's markup:

lwc/faq/faq.html
1link<c-pager

2link class="pager"

3link pagedata="{faqs.data}"

4link title="FAQ"

5link onpagerchanged="{handlePagerChange}"

6link></c-pager>

7link<!-- ... -->

And in the FAQ's controller:

lwc/faq/faq.js
1linkimport { api, LightningElement, wire } from "lwc";

2linkimport getFAQs from "@salesforce/apex/FAQController.getFAQs";

3link

4linkconst PAGER_NAME = "c-pager";

5link

6linkexport default class FAQList extends LightningElement {

7link @wire(getFAQs) faqs;

8link _currentlyVisible = [];

9link

10link _getPagesOrDefault() {

11link const pager = this.template.querySelector(PAGER_NAME);

12link return !!pager ? pager.currentlyShown : [];

13link }

14link

15link @api

16link get currentlyVisible() {

17link const pages = this._getPagesOrDefault();

18link return pages.length === 0 ? this._currentlyVisible : pages;

19link }

20link set currentlyVisible(value) {

21link this._currentlyVisible = value;

22link }

23link

24link handlePagerChanged() {

25link this.currentlyVisible = this._getPagesOrDefault();

26link }

27link}

Great. We're back on track. The FAQ now loads the first five elements correctly. In a truly shared library, I would probably be importing this handlePagerChange function from a utils folder, or other shared-logic namespace. You can accomplish this in a variety of ways, but as an example:

lwc/utils/pagerUtils.js
1linkconst PAGER_NAME = "c-pager";

2link

3linkexport function getPagesOrDefault() {

4link const pager = this.template.querySelector(PAGER_NAME);

5link return !!pager ? pager.currentlyShown : [];

6link}

7link

8linkexport function handlePagerChanged() {

9link this.currentlyVisible = this.getPagesOrDefault();

10link}

And in the FAQ's JS controller we can now make use of those shared functions to alleviate the implementation burden:

lwc/faq/faq.js
1linkimport { api, LightningElement, wire } from "lwc";

2linkimport getFAQs from "@salesforce/apex/FAQController.getFAQs";

3linkimport { getPagesOrDefault, handlePagerChanged } from "c/pagerUtils";

4link

5linkexport default class FAQList extends LightningElement {

6link @wire(getFAQs) faqs;

7link _currentlyVisible = [];

8link

9link getPagesOrDefault = getPagesOrDefault.bind(this);

10link handlePagerChanged = handlePagerChanged.bind(this);

11link

12link @api

13link get currentlyVisible() {

14link const pages = this.getPagesOrDefault();

15link return pages.length === 0 ? this._currentlyVisible : pages;

16link }

17link set currentlyVisible(value) {

18link this._currentlyVisible = value;

19link }

20link}

Since every consumer of the pager will need to use it, it's also tempting to derive another base component that extends off of LightningElement. That would be a framework-level trap, unfortunately, as is evidenced by this sage tip dispensed within the LWC docs:

Inheritance is allowed, but it isn’t recommended because composition is usually more effective. To share logic between components, use a module that contains only logic. If you do choose to use inheritance, note that it doesn’t work across namespaces.

Unlucky. Solving for greater code reusability between components is one of the framework-level issues that I would really like to see improved upon in Lightning Web Components, especially because the @api decorated getters and setters for functions can't be shared between components. Now, to make use of c-pager, I have this unwieldy coupling between:

React "solved" this problem with higher-ordered components and higher-ordered (curried, really) functions. Since I don't have the option of enforcing this property to exist on each component making use of the pager, I will just leave off by saying that it makes the compositional re-use of a component like this more verbose than should really be necessary.

linkReturning To Pagination

To get the bare minimum necessary to paginate (now that the data is being properly filtered by the pager), all we need to do is wire up some click handlers for the pager's next and previous buttons and emit the same pagerchanged event. Compared to the hoops we just jumped through, this is a walk in the park!

lwc/pager/pager.html
1link<lightning-button-icon

2link alternative-text="Previous"

3link class="slds-float_left"

4link icon-class="slds-m-around_medium"

5link icon-name="utility:chevronleft"

6link onclick="{handlePrevious}"

7link variant="bare"

8link>

9link</lightning-button-icon>

10link<lightning-button-icon

11link alternative-text="Next"

12link class="slds-float_right"

13link icon-class="slds-m-around_medium"

14link icon-name="utility:chevronright"

15link onclick="{handleNext}"

16link variant="bare"

17link></lightning-button-icon>

You can probably guess what the click handlers look like:

lwc/pager/pager.js
1linkhandlePrevious() {

2link this.currentPageIndex =

3link this.currentPageIndex > 0 ? this.currentPageIndex - 1 : 0;

4link this.dispatchEvent(new CustomEvent("pagerchanged"));

5link}

6link

7linkhandleNext() {

8link this.currentPageIndex =

9link this.currentPageIndex < this.maxNumberOfPages

10link ? this.currentPageIndex + 1

11link : this.maxNumberOfPages;

12link this.dispatchEvent(new CustomEvent("pagerchanged"));

13link}

The bare minimum pager now has the following concepts encapsulated:

That's pretty nice — and it might be enough for your use-case. Still, I think it would be hard to argue that a classic paging component was finished without the intermediary pages available.

linkSetting Up Page Ranges

Are you familiar with the Pareto Principle? As a student of economics, Vilfredo Pareto's observations are widely discussed in classrooms. It was somewhat surprising that when I entered the world of software engineering, I found Pareto there waiting for me. Generally speaking, Pareto's principle can be stated as:

80% of the functionality comes from 20% of the work.

As I sat writing the pager for this article, I was reminded of Pareto. I've done pagination a handful of times now, and there's always some part of it that ends up being a bit more difficult than the other parts. Showing the page ranges in a satisfying way ended up being that "more difficult" part of this journey. Partially this was because I originally chose to exhibit the page ranges using lightning-button components; they looked great, but if you've used SLDS, you'll know you can't reliably override the base SLDS styles. The base lightning-button component also doesn't support the slds-is-active stateful representation of clicked/unclicked. The stateful buttons require the use of an icon; while there are some really tremendous icons in the Lightning Design System, there aren't any that represent numbers, making them ill-suited for our use-case in displaying page numbers. In any case, I ended up going with the plain HTML button to represent the pager's page ranges:

Showing the page ranges for the LWC

So what did the implementation end up looking like? Let's dive in, styles first:

lwc/pager/pager.css
1link:host {

2link --white: rgb(255, 255, 255);

3link --active: #f5edcc;

4link}

5link

6link.page-data-container {

7link background-color: var(--white);

8link max-height: 400px;

9link overflow: hidden;

10link}

11link

12linkbutton {

13link background-color: var(--white);

14link border: none;

15link border-radius: 0.5rem;

16link padding: 5px 10px;

17link}

18link

19link.active {

20link background-color: var(--active);

21link}

And the markup:

lwc/pager/pager.html
1link<template>

2link <section>

3link <div class="page-data-container">

4link <h1

5link class="slds-text-heading_large slds-align_absolute-center slds-m-top_small"

6link >

7link {title}

8link </h1>

9link <slot></slot>

10link </div>

11link <div class="slds-align_absolute-center page-data-container">

12link <lightning-button-icon

13link alternative-text="Previous"

14link class="slds-float_left"

15link icon-class="slds-m-around_medium"

16link icon-name="utility:chevronleft"

17link onclick="{handlePrevious}"

18link variant="bare"

19link >

20link </lightning-button-icon>

21link <template

22link for:each="{currentVisiblePageRanges}"

23link for:item="currentlyVisible"

24link >

25link <button

26link class="page-index slds-m-around_x-small"

27link key="{currentlyVisible}"

28link onclick="{handleClick}"

29link title="{currentlyVisible}"

30link >

31link {currentlyVisible}

32link </button>

33link </template>

34link <lightning-button-icon

35link alternative-text="Next"

36link class="slds-float_right"

37link icon-class="slds-m-around_medium"

38link icon-name="utility:chevronright"

39link onclick="{handleNext}"

40link variant="bare"

41link >

42link </lightning-button-icon>

43link </div>

44link </section>

45link</template>

Now the next/previous buttons are grouped into the absolute center of the component, as you've seen, and we're using another for:each iterator to go through a currentVisiblePageRanges property. Let's take a look at the finished JS controller:

lwc/pager/pager.js
1linkimport { api, LightningElement, track } from "lwc";

2link

3linkconst IS_ACTIVE = "active";

4link

5linkexport default class Pager extends LightningElement {

6link @api pagedata = [];

7link @api title = "";

8link

9link @track currentPageIndex = 0;

10link @track maxNumberOfPages = 0;

11link MAX_PAGES_TO_SHOW = 5;

12link

13link _pageRange = [];

14link

15link @api

16link get currentlyShown() {

17link const currentPage = this.MAX_PAGES_TO_SHOW * this.currentPageIndex;

18link const pageStartRange =

19link currentPage >= this.pagedata.length

20link ? this.pagedata.length - this.MAX_PAGES_TO_SHOW

21link : currentPage;

22link const pageEndRange =

23link this.currentPageIndex === 0

24link ? this.MAX_PAGES_TO_SHOW

25link : pageStartRange + this.MAX_PAGES_TO_SHOW;

26link

27link return this.pagedata.slice(pageStartRange, pageEndRange);

28link }

29link

30link @api

31link get currentVisiblePageRanges() {

32link if (this._pageRange.length === 0) {

33link this._pageRange = this._fillRange(

34link this.currentPageIndex * this.MAX_PAGES_TO_SHOW,

35link this.MAX_PAGES_TO_SHOW

36link );

37link }

38link return this._pageRange;

39link }

40link set currentVisiblePageRanges(nextRange) {

41link const lastPossibleRange =

42link nextRange + this.MAX_PAGES_TO_SHOW > this.maxNumberOfPages

43link ? this.maxNumberOfPages

44link : nextRange + this.MAX_PAGES_TO_SHOW;

45link this._pageRange = this._fillRange(

46link lastPossibleRange - this.MAX_PAGES_TO_SHOW,

47link lastPossibleRange

48link );

49link }

50link

51link renderedCallback() {

52link this.maxNumberOfPages = !!this.pagedata

53link ? this.pagedata.length / this.MAX_PAGES_TO_SHOW

54link : 0;

55link this.currentShownPages =

56link this.maxNumberOfPages <= this.MAX_PAGES_TO_SHOW

57link ? this.maxNumberOfPages

58link : this.MAX_PAGES_TO_SHOW;

59link this.dispatchEvent(new CustomEvent("pagerchanged"));

60link if ([...this.template.querySelectorAll("button.active")].length === 0) {

61link //first render

62link this._highlightPageButtonAtIndex(1);

63link }

64link }

65link

66link handlePrevious() {

67link this.currentPageIndex =

68link this.currentPageIndex > 0 ? this.currentPageIndex - 1 : 0;

69link this.currentVisiblePageRanges =

70link this.currentPageIndex - 1 <= 0 ? 1 : this.currentPageIndex - 1;

71link this.dispatchEvent(new CustomEvent("pagerchanged"));

72link this._highlightPageButtonAtIndex(

73link this.currentPageIndex <= 0 ? 1 : this.currentPageIndex - 1

74link );

75link }

76link

77link handleNext() {

78link this.currentPageIndex =

79link this.currentPageIndex < this.maxNumberOfPages

80link ? this.currentPageIndex + 1

81link : this.maxNumberOfPages;

82link this.currentVisiblePageRanges =

83link this.currentPageIndex <= this.maxNumberOfPages

84link ? this.currentPageIndex

85link : this.currentPageIndex + 1;

86link this.dispatchEvent(new CustomEvent("pagerchanged"));

87link this._highlightPageButtonAtIndex(

88link this.currentPageIndex >= this.maxNumberOfPages

89link ? this.maxNumberOfPages

90link : this.currentPageIndex + 1

91link );

92link }

93link

94link handleClick(event) {

95link this.currentPageIndex = parseInt(event.target.innerHTML);

96link this.currentVisiblePageRanges = this.currentPageIndex;

97link this._clearCurrentlyActive();

98link event.target.classList.toggle(IS_ACTIVE);

99link }

100link

101link _clearCurrentlyActive() {

102link const alreadySelected = [

103link ...this.template.querySelectorAll("." + IS_ACTIVE),

104link ];

105link if (alreadySelected.length === 1) {

106link alreadySelected[0].classList.toggle(IS_ACTIVE);

107link }

108link }

109link

110link _fillRange(start, end) {

111link const safeEnd = end < start ? start + this.MAX_PAGES_TO_SHOW : end;

112link return Array(safeEnd - start)

113link .fill()

114link .map((_, index) => (start === 0 ? 1 + index : start + index));

115link }

116link

117link _highlightPageButtonAtIndex(pageNumber) {

118link this._clearCurrentlyActive();

119link const pageButtons = [...this.template.querySelectorAll("button")];

120link const firstButton = pageButtons.filter(

121link (button) => button.textContent === String(pageNumber)

122link );

123link if (firstButton.length === 1) {

124link firstButton[0].classList.toggle(IS_ACTIVE);

125link }

126link }

127link}

There's a lot to consider here. The pager has to handle quite a few distinct responsibilities when considering the page ranges:

linkWrapping Up

The pager is ready to be used! It can take in any generic list of other components and dictate how many of those components should be displayed. An exercise left to the reader would be modifying the MAX_PAGES_TO_SHOW constant to instead be a property configurable by a select element within the pager. That way you can expand the height of the pager programmatically to show the full data-set when requested by a user.

I've pushed the entirety of this example to the LWC Pager repository on my github for you to browse. I hope that you enjoyed this compositional journey into the innards of Lightning Web Components! I've been wanting to open-source some of my LWC work for a while now, and I feel that pagination is such a common — but suitably complex enough — problem that it might prove useful to others.

Thanks for being a part of this Joys Of Apex journey with me. Looking forward to the next time we meet here!

The original version of Lightning Web Components: Composable Pagination can be read on my blog.

Slot-Based CompositionStarting To PaginateStarting To Actually PaginateAn Aside On LWC Lifecycle HooksReturning To PaginationSetting Up Page RangesWrapping Up

Home Apex Logging Service Apex Object-Oriented Basics Batchable And Queueable Apex Building A Better Singleton Continuous Integration With SFDX Dependency Injection & Factory Pattern Enum Apex Class Gotchas Extendable Apis Future Methods, Callouts & Callbacks Idiomatic Salesforce Apex Introduction & Testing Philosophy Lazy Iterators Lightweight Trigger Handler LWC Composable Modal LWC Composable Pagination LWC Custom Lead Path Mocking DML React Versus Lightning Web Components Refactoring Tips & Tricks Repository Pattern setTimeout & Implementing Delays Sorting And Performance In Apex Test Driven Development Example Testing Custom Permissions Writing Performant Apex Tests



Read more tech articles