diff --git a/README.md b/README.md index 9c62acddb..fc1469289 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ - [Configuration](#configuration) - [Reference Documentation](#reference-documentation) - [Contentful Javascript resources](#contentful-javascript-resources) + - [Cursor Based Pagination](#cursor-based-pagination) - [REST API reference](#rest-api-reference) - [Versioning](#versioning) - [Reach out to us](#reach-out-to-us) @@ -226,6 +227,17 @@ The benefits of using the "plain" version of the client, over the legacy version - The ability to scope CMA client instance to a specific `spaceId`, `environmentId`, and `organizationId` when initializing the client. - You can pass a concrete values to `defaults` and omit specifying these params in actual CMA methods calls. +## Cursor Based Pagination + +Cursor-based pagination is supported on collection endpoints for content types, entries, and assets. To use cursor-based pagination, use the related entity methods `getAssetsWithCursor`, `getContentTypesWithCursor`, and `getEntriesWithCursor` + +```js +const response = await environment.getEntriesWithCursor({ limit: 10 }); +console.log(response.items); // Array of items +console.log(response.pages?.next); // Cursor for next page +``` +Use the value from `response.pages.next` to fetch the next page. + ## Legacy Client Interface The following code snippet is an example of the legacy client interface, which reads and writes data as a sequence of nested requests: diff --git a/lib/common-types.ts b/lib/common-types.ts index 76b6b9626..1a5483801 100644 --- a/lib/common-types.ts +++ b/lib/common-types.ts @@ -359,6 +359,7 @@ export interface BasicQueryOptions { } export interface BasicCursorPaginationOptions extends Omit { + skip?: never pageNext?: string pagePrev?: string } @@ -487,6 +488,7 @@ type MRInternal = { ): MRReturn<'AppInstallation', 'getForOrganization'> (opts: MROpts<'Asset', 'getMany', UA>): MRReturn<'Asset', 'getMany'> + (opts: MROpts<'Asset', 'getManyWithCursor', UA>): MRReturn<'Asset', 'getManyWithCursor'> (opts: MROpts<'Asset', 'getPublished', UA>): MRReturn<'Asset', 'getPublished'> (opts: MROpts<'Asset', 'get', UA>): MRReturn<'Asset', 'get'> (opts: MROpts<'Asset', 'update', UA>): MRReturn<'Asset', 'update'> @@ -567,6 +569,9 @@ type MRInternal = { (opts: MROpts<'ContentType', 'get', UA>): MRReturn<'ContentType', 'get'> (opts: MROpts<'ContentType', 'getMany', UA>): MRReturn<'ContentType', 'getMany'> + ( + opts: MROpts<'ContentType', 'getManyWithCursor', UA>, + ): MRReturn<'ContentType', 'getManyWithCursor'> (opts: MROpts<'ContentType', 'update', UA>): MRReturn<'ContentType', 'update'> (opts: MROpts<'ContentType', 'create', UA>): MRReturn<'ContentType', 'create'> (opts: MROpts<'ContentType', 'createWithId', UA>): MRReturn<'ContentType', 'createWithId'> @@ -616,6 +621,7 @@ type MRInternal = { ): MRReturn<'EnvironmentTemplateInstallation', 'getForEnvironment'> (opts: MROpts<'Entry', 'getMany', UA>): MRReturn<'Entry', 'getMany'> + (opts: MROpts<'Entry', 'getManyWithCursor', UA>): MRReturn<'Entry', 'getManyWithCursor'> (opts: MROpts<'Entry', 'getPublished', UA>): MRReturn<'Entry', 'getPublished'> (opts: MROpts<'Entry', 'get', UA>): MRReturn<'Entry', 'get'> (opts: MROpts<'Entry', 'patch', UA>): MRReturn<'Entry', 'patch'> @@ -1234,6 +1240,11 @@ export type MRActions = { headers?: RawAxiosRequestHeaders return: CollectionProp } + getManyWithCursor: { + params: GetSpaceEnvironmentParams & CursorBasedParams & { releaseId?: string } + headers?: RawAxiosRequestHeaders + return: CursorPaginatedCollectionProp + } get: { params: GetSpaceEnvironmentParams & { assetId: string; releaseId?: string } & QueryParams headers?: RawAxiosRequestHeaders @@ -1482,6 +1493,10 @@ export type MRActions = { params: GetSpaceEnvironmentParams & QueryParams return: CollectionProp } + getManyWithCursor: { + params: GetSpaceEnvironmentParams & CursorBasedParams + return: CursorPaginatedCollectionProp + } create: { params: GetSpaceEnvironmentParams payload: CreateContentTypeProps @@ -1650,6 +1665,10 @@ export type MRActions = { params: GetSpaceEnvironmentParams & QueryParams & { releaseId?: string } return: CollectionProp> } + getManyWithCursor: { + params: GetSpaceEnvironmentParams & CursorBasedParams & { releaseId?: string } + return: CursorPaginatedCollectionProp> + } get: { params: GetSpaceEnvironmentParams & { entryId: string; releaseId?: string } & QueryParams return: EntryProps diff --git a/lib/common-utils.ts b/lib/common-utils.ts index 0da3ea5f8..a4ef5582b 100644 --- a/lib/common-utils.ts +++ b/lib/common-utils.ts @@ -3,8 +3,10 @@ import { toPlainObject } from 'contentful-sdk-core' import copy from 'fast-copy' import type { + BasicCursorPaginationOptions, Collection, CollectionProp, + CursorBasedParams, CursorPaginatedCollection, CursorPaginatedCollectionProp, MakeRequest, @@ -47,3 +49,50 @@ export function shouldRePoll(statusCode: number) { export async function waitFor(ms = 1000) { return new Promise((resolve) => setTimeout(resolve, ms)) } + +export function normalizeCursorPaginationParameters( + query: BasicCursorPaginationOptions, +): CursorBasedParams { + const { pagePrev, pageNext, ...rest } = query + + return { + ...rest, + cursor: true, + // omit pagePrev and pageNext if the value is falsy + ...(pagePrev ? { pagePrev } : null), + ...(pageNext ? { pageNext } : null), + } as CursorBasedParams +} + +function extractQueryParam(key: string, url?: string): string | undefined { + if (!url) return + + const queryIndex = url.indexOf('?') + if (queryIndex === -1) return + + const queryString = url.slice(queryIndex + 1) + return new URLSearchParams(queryString).get(key) ?? undefined +} + +const Pages = { + prev: 'pagePrev', + next: 'pageNext', +} as const + +const PAGE_KEYS = ['prev', 'next'] as const + +export function normalizeCursorPaginationResponse( + data: CursorPaginatedCollectionProp, +): CursorPaginatedCollectionProp { + const pages: { prev?: string; next?: string } = {} + + for (const key of PAGE_KEYS) { + const token = extractQueryParam(Pages[key], data.pages?.[key]) + if (token) pages[key] = token + } + + return { + ...data, + pages, + } +} diff --git a/lib/create-environment-api.ts b/lib/create-environment-api.ts index fb20b81f9..117f1063d 100644 --- a/lib/create-environment-api.ts +++ b/lib/create-environment-api.ts @@ -7,6 +7,10 @@ import type { CursorBasedParams, QueryOptions, } from './common-types' +import { + normalizeCursorPaginationParameters, + normalizeCursorPaginationResponse, +} from './common-utils' import type { BasicQueryOptions, MakeRequest } from './common-types' import entities from './entities' import type { CreateAppInstallationProps } from './entities/app-installation' @@ -15,11 +19,11 @@ import type { CreateAppActionCallProps, AppActionCallRawResponseProps, } from './entities/app-action-call' -import type { - AssetFileProp, - AssetProps, - CreateAssetFromFilesOptions, - CreateAssetProps, +import { + type AssetFileProp, + type AssetProps, + type CreateAssetFromFilesOptions, + type CreateAssetProps, } from './entities/asset' import type { CreateAssetKeyProps } from './entities/asset-key' import type { @@ -40,12 +44,12 @@ import type { } from './entities/release' import { wrapRelease, wrapReleaseCollection } from './entities/release' -import type { ContentTypeProps, CreateContentTypeProps } from './entities/content-type' -import type { - CreateEntryProps, - EntryProps, - EntryReferenceOptionsProps, - EntryReferenceProps, +import { type ContentTypeProps, type CreateContentTypeProps } from './entities/content-type' +import { + type CreateEntryProps, + type EntryProps, + type EntryReferenceOptionsProps, + type EntryReferenceProps, } from './entities/entry' import type { EnvironmentProps } from './entities/environment' import type { CreateExtensionProps } from './entities/extension' @@ -75,9 +79,10 @@ export type ContentfulEnvironmentAPI = ReturnType */ export default function createEnvironmentApi(makeRequest: MakeRequest) { const { wrapEnvironment } = entities.environment - const { wrapContentType, wrapContentTypeCollection } = entities.contentType - const { wrapEntry, wrapEntryCollection } = entities.entry - const { wrapAsset, wrapAssetCollection } = entities.asset + const { wrapContentType, wrapContentTypeCollection, wrapContentTypeCursorPaginatedCollection } = + entities.contentType + const { wrapEntry, wrapEntryCollection, wrapEntryTypeCursorPaginatedCollection } = entities.entry + const { wrapAsset, wrapAssetCollection, wrapAssetTypeCursorPaginatedCollection } = entities.asset const { wrapAssetKey } = entities.assetKey const { wrapLocale, wrapLocaleCollection } = entities.locale const { wrapSnapshotCollection } = entities.snapshot @@ -492,6 +497,44 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) { }, }).then((data) => wrapContentTypeCollection(makeRequest, data)) }, + + /** + * Gets a collection of Content Types with cursor based pagination + * @param query - Object with search parameters. Check the JS SDK tutorial and the REST API reference for more details. + * @return Promise for a collection of Content Types + * @example ```javascript + * const contentful = require('contentful-management') + * + * const client = contentful.createClient({ + * accessToken: '' + * }) + * + * client.getSpace('') + * .then((space) => space.getEnvironment('')) + * .then((environment) => environment.getContentTypesWithCursor()) + * .then((response) => console.log(response.items)) + * .catch(console.error) + * ``` + */ + getContentTypesWithCursor(query: BasicCursorPaginationOptions = {}) { + const raw = this.toPlainObject() as EnvironmentProps + const normalizedQueryParams = normalizeCursorPaginationParameters(query) + return makeRequest({ + entityType: 'ContentType', + action: 'getMany', + params: { + spaceId: raw.sys.space.sys.id, + environmentId: raw.sys.id, + query: createRequestConfig({ query: normalizedQueryParams }).params, + }, + }).then((data) => + wrapContentTypeCursorPaginatedCollection( + makeRequest, + normalizeCursorPaginationResponse(data), + ), + ) + }, + /** * Creates a Content Type * @param data - Object representation of the Content Type to be created @@ -740,6 +783,45 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) { }).then((data) => wrapEntryCollection(makeRequest, data)) }, + /** + * Gets a collection of Entries with cursor based pagination + * Warning: if you are using the select operator, when saving, any field that was not selected will be removed + * from your entry in the backend + * @param query - Object with search parameters. Check the JS SDK tutorial and the REST API reference for more details. + * @return Promise for a collection of Entries + * @example ```javascript + * const contentful = require('contentful-management') + * + * const client = contentful.createClient({ + * accessToken: '' + * }) + * + * client.getSpace('') + * .then((space) => space.getEnvironment('')) + * .then((environment) => environment.getEntriesWithCursor({'content_type': 'foo'})) // you can add more queries as 'key': 'value' + * .then((response) => console.log(response.items)) + * .catch(console.error) + * ``` + */ + getEntriesWithCursor(query: BasicCursorPaginationOptions = {}) { + const raw = this.toPlainObject() as EnvironmentProps + const normalizedQueryParams = normalizeCursorPaginationParameters(query) + return makeRequest({ + entityType: 'Entry', + action: 'getMany', + params: { + spaceId: raw.sys.space.sys.id, + environmentId: raw.sys.id, + query: createRequestConfig({ query: normalizedQueryParams }).params, + }, + }).then((data) => + wrapEntryTypeCursorPaginatedCollection( + makeRequest, + normalizeCursorPaginationResponse(data), + ), + ) + }, + /** * Gets a collection of published Entries * @param query - Object with search parameters. Check the JS SDK tutorial and the REST API reference for more details. @@ -955,6 +1037,46 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) { }, }).then((data) => wrapAssetCollection(makeRequest, data)) }, + + /** + * Gets a collection of Assets with cursor based pagination + * Warning: if you are using the select operator, when saving, any field that was not selected will be removed + * from your entry in the backend + * @param query - Object with search parameters. Check the JS SDK tutorial and the REST API reference for more details. + * @return Promise for a collection of Assets + * @example ```javascript + * const contentful = require('contentful-management') + * + * const client = contentful.createClient({ + * accessToken: '' + * }) + * + * client.getSpace('') + * .then((space) => space.getEnvironment('')) + * .then((environment) => environment.getAssetsWithCursor()) + * .then((response) => console.log(response.items)) + * .catch(console.error) + * ``` + */ + getAssetsWithCursor(query: BasicCursorPaginationOptions = {}) { + const raw = this.toPlainObject() as EnvironmentProps + const normalizedQueryParams = normalizeCursorPaginationParameters(query) + return makeRequest({ + entityType: 'Asset', + action: 'getMany', + params: { + spaceId: raw.sys.space.sys.id, + environmentId: raw.sys.id, + query: createRequestConfig({ query: normalizedQueryParams }).params, + }, + }).then((data) => + wrapAssetTypeCursorPaginatedCollection( + makeRequest, + normalizeCursorPaginationResponse(data), + ), + ) + }, + /** * Gets a collection of published Assets * @param query - Object with search parameters. Check the JS SDK tutorial and the REST API reference for more details. @@ -985,6 +1107,7 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) { }, }).then((data) => wrapAssetCollection(makeRequest, data)) }, + /** * Creates a Asset. After creation, call asset.processForLocale or asset.processForAllLocales to start asset processing. * @param data - Object representation of the Asset to be created. Note that the field object should have an upload property on asset creation, which will be removed and replaced with an url property when processing is finished. diff --git a/lib/entities/asset.ts b/lib/entities/asset.ts index 332b2ebf8..cd63c9f10 100644 --- a/lib/entities/asset.ts +++ b/lib/entities/asset.ts @@ -8,8 +8,9 @@ import type { EntityMetaSysProps, MetadataProps, MakeRequest, + CursorPaginatedCollectionProp, } from '../common-types' -import { wrapCollection } from '../common-utils' +import { wrapCollection, wrapCursorPaginatedCollection } from '../common-utils' import * as checks from '../plain/checks' export type AssetProps = { @@ -410,3 +411,8 @@ export function wrapAsset(makeRequest: MakeRequest, data: AssetProps): Asset { * @private */ export const wrapAssetCollection = wrapCollection(wrapAsset) + +/** + * @private + */ +export const wrapAssetTypeCursorPaginatedCollection = wrapCursorPaginatedCollection(wrapAsset) diff --git a/lib/entities/content-type.ts b/lib/entities/content-type.ts index cb8d3e40b..fb8402a16 100644 --- a/lib/entities/content-type.ts +++ b/lib/entities/content-type.ts @@ -4,13 +4,14 @@ import type { Except, RequireAtLeastOne, SetOptional } from 'type-fest' import type { BasicMetaSysProps, Collection, + CursorPaginatedCollectionProp, DefaultElements, Link, MakeRequest, QueryOptions, SysLink, } from '../common-types' -import { wrapCollection } from '../common-utils' +import { wrapCollection, wrapCursorPaginatedCollection } from '../common-utils' import enhanceWithMethods from '../enhance-with-methods' import { isDraft, isPublished, isUpdated } from '../plain/checks' import type { ContentFields } from './content-type-fields' @@ -359,3 +360,9 @@ export function wrapContentType(makeRequest: MakeRequest, data: ContentTypeProps * @private */ export const wrapContentTypeCollection = wrapCollection(wrapContentType) + +/** + * @private + */ +export const wrapContentTypeCursorPaginatedCollection = + wrapCursorPaginatedCollection(wrapContentType) diff --git a/lib/entities/entry.ts b/lib/entities/entry.ts index afc0bb473..31c65559b 100644 --- a/lib/entities/entry.ts +++ b/lib/entities/entry.ts @@ -2,13 +2,14 @@ import { freezeSys, toPlainObject } from 'contentful-sdk-core' import copy from 'fast-copy' import type { CollectionProp, + CursorPaginatedCollectionProp, DefaultElements, EntryMetaSysProps, KeyValueMap, MakeRequest, MetadataProps, } from '../common-types' -import { wrapCollection } from '../common-utils' +import { wrapCollection, wrapCursorPaginatedCollection } from '../common-utils' import type { ContentfulEntryApi } from '../create-entry-api' import createEntryApi from '../create-entry-api' import enhanceWithMethods from '../enhance-with-methods' @@ -71,3 +72,8 @@ export function wrapEntry(makeRequest: MakeRequest, data: EntryProps): Entry { * @private */ export const wrapEntryCollection = wrapCollection(wrapEntry) + +/** + * @private + */ +export const wrapEntryTypeCursorPaginatedCollection = wrapCursorPaginatedCollection(wrapEntry) diff --git a/lib/plain/common-types.ts b/lib/plain/common-types.ts index 1a98cf55d..904a45aa1 100644 --- a/lib/plain/common-types.ts +++ b/lib/plain/common-types.ts @@ -8,6 +8,7 @@ import type { CreateWithFilesReleaseAssetParams, CreateWithIdReleaseAssetParams, CreateWithIdReleaseEntryParams, + CursorBasedParams, CursorPaginatedCollectionProp, EnvironmentTemplateParams, GetBulkActionParams, @@ -274,6 +275,9 @@ export type PlainClientAPI = { getMany( params: OptionalDefaults, ): Promise> + getManyWithCursor( + params: OptionalDefaults, + ): Promise> update( params: OptionalDefaults, rawData: ContentTypeProps, @@ -311,6 +315,13 @@ export type PlainClientAPI = { rawData?: unknown, headers?: RawAxiosRequestHeaders, ): Promise>> + getManyWithCursor( + params: OptionalDefaults< + GetSpaceEnvironmentParams & CursorBasedParams & { releaseId?: string } + >, + rawData?: unknown, + headers?: RawAxiosRequestHeaders, + ): Promise>> get( params: OptionalDefaults, rawData?: unknown, @@ -375,6 +386,13 @@ export type PlainClientAPI = { rawData?: unknown, headers?: RawAxiosRequestHeaders, ): Promise> + getManyWithCursor( + params: OptionalDefaults< + GetSpaceEnvironmentParams & CursorBasedParams & { releaseId?: string } + >, + rawData?: unknown, + headers?: RawAxiosRequestHeaders, + ): Promise> get( params: OptionalDefaults< GetSpaceEnvironmentParams & { assetId: string; releaseId?: string } & QueryParams diff --git a/lib/plain/plain-client.ts b/lib/plain/plain-client.ts index d2ff42ec5..dfa052816 100644 --- a/lib/plain/plain-client.ts +++ b/lib/plain/plain-client.ts @@ -217,6 +217,7 @@ export const createPlainClient = ( contentType: { get: wrap(wrapParams, 'ContentType', 'get'), getMany: wrap(wrapParams, 'ContentType', 'getMany'), + getManyWithCursor: wrap(wrapParams, 'ContentType', 'getManyWithCursor'), update: wrap(wrapParams, 'ContentType', 'update'), delete: wrap(wrapParams, 'ContentType', 'delete'), publish: wrap(wrapParams, 'ContentType', 'publish'), @@ -247,6 +248,7 @@ export const createPlainClient = ( entry: { getPublished: wrap(wrapParams, 'Entry', 'getPublished'), getMany: wrap(wrapParams, 'Entry', 'getMany'), + getManyWithCursor: wrap(wrapParams, 'Entry', 'getManyWithCursor'), get: wrap(wrapParams, 'Entry', 'get'), update: wrap(wrapParams, 'Entry', 'update'), patch: wrap(wrapParams, 'Entry', 'patch'), @@ -262,6 +264,7 @@ export const createPlainClient = ( asset: { getPublished: wrap(wrapParams, 'Asset', 'getPublished'), getMany: wrap(wrapParams, 'Asset', 'getMany'), + getManyWithCursor: wrap(wrapParams, 'Asset', 'getManyWithCursor'), get: wrap(wrapParams, 'Asset', 'get'), update: wrap(wrapParams, 'Asset', 'update'), delete: wrap(wrapParams, 'Asset', 'delete'), diff --git a/test/integration/asset-integration.test.ts b/test/integration/asset-integration.test.ts index 02b4ec284..17d3339da 100644 --- a/test/integration/asset-integration.test.ts +++ b/test/integration/asset-integration.test.ts @@ -39,6 +39,73 @@ describe('Asset API - Read', () => { expect(response.items).toBeTruthy() }) + describe('Gets assets with cursor pagination', () => { + test('gets assets with cursor pagination with items', async () => { + const response = await environment.getAssetsWithCursor() + expect(response.items).toBeTruthy() + }) + + test('returns a cursor paginated asset collection when no query is provided', async () => { + const response = await environment.getAssetsWithCursor() + + expect(response.items).not.toHaveLength(0) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Asset') + expect(item.fields).toBeDefined() + }) + }) + + test('returns [limit] number of items', async () => { + const response = await environment.getAssetsWithCursor({ limit: 3 }) + + expect(response.items).toHaveLength(3) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Asset') + expect(item.fields).toBeDefined() + }) + }) + + test('supports forward pagination', async () => { + const firstPage = await environment.getAssetsWithCursor({ limit: 2 }) + const secondPage = await environment.getAssetsWithCursor({ + limit: 2, + pageNext: firstPage?.pages?.next, + }) + + expect(secondPage.items).toHaveLength(2) + expect(firstPage.items[0].sys.id).not.toEqual(secondPage.items[0].sys.id) + }) + + test('should support backward pagination', async () => { + const firstPage = await environment.getAssetsWithCursor({ + limit: 2, + order: ['sys.createdAt'], + }) + const secondPage = await environment.getAssetsWithCursor({ + limit: 2, + pageNext: firstPage?.pages?.next, + order: ['sys.createdAt'], + }) + const result = await environment.getAssetsWithCursor({ + limit: 2, + pagePrev: secondPage?.pages?.prev, + order: ['sys.createdAt'], + }) + + expect(result.items).toHaveLength(2) + + firstPage.items.forEach((item, index) => { + expect(item.sys.id).equal(result.items[index].sys.id) + }) + }) + }) + test('Gets published assets', async () => { const response = await environment.getPublishedAssets() expect(response.items).toBeTruthy() diff --git a/test/integration/content-type-integration.test.ts b/test/integration/content-type-integration.test.ts index 5f67d5847..2dac9aa71 100644 --- a/test/integration/content-type-integration.test.ts +++ b/test/integration/content-type-integration.test.ts @@ -51,6 +51,76 @@ describe('ContentType Api', () => { const response = await readEnvironment.getContentTypes() expect(response.items).toBeTruthy() }) + + describe('Gets content types with cursor pagination', () => { + it('gets content types with cursor pagination with items', async () => { + const response = await readEnvironment.getContentTypesWithCursor() + expect(response.items).toBeTruthy() + }) + + it('returns a cursor paginated content type collection when no query is provided', async () => { + const response = await readEnvironment.getContentTypesWithCursor() + + expect(response.items).not.toHaveLength(0) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((ct) => { + expect(ct.sys.type).toEqual('ContentType') + expect(ct.name).toBeDefined() + expect(ct.fields).toBeDefined() + expect(Array.isArray(ct.fields)).toBe(true) + }) + }) + + it('returns [limit] number of items', async () => { + const response = await readEnvironment.getContentTypesWithCursor({ limit: 3 }) + + expect(response.items).toHaveLength(3) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((ct) => { + expect(ct.sys.type).toEqual('ContentType') + expect(ct.name).toBeDefined() + expect(Array.isArray(ct.fields)).toBe(true) + }) + }) + + it('supports forward pagination', async () => { + const firstPage = await readEnvironment.getContentTypesWithCursor({ limit: 2 }) + const secondPage = await readEnvironment.getContentTypesWithCursor({ + limit: 2, + pageNext: firstPage?.pages?.next, + }) + + expect(secondPage.items).toHaveLength(2) + expect(firstPage.items[0].sys.id).not.toEqual(secondPage.items[0].sys.id) + }) + + it('should support backward pagination', async () => { + const firstPage = await readEnvironment.getContentTypesWithCursor({ + limit: 2, + order: ['sys.createdAt'], + }) + const secondPage = await readEnvironment.getContentTypesWithCursor({ + limit: 2, + pageNext: firstPage?.pages?.next, + order: ['sys.createdAt'], + }) + const result = await readEnvironment.getContentTypesWithCursor({ + limit: 2, + pagePrev: secondPage?.pages?.prev, + order: ['sys.createdAt'], + }) + + expect(result.items).toHaveLength(2) + + firstPage.items.forEach((item, index) => { + expect(item.sys.id).toEqual(result.items[index].sys.id) + }) + }) + }) }) describe('write', () => { diff --git a/test/integration/entry-integration.test.ts b/test/integration/entry-integration.test.ts index cfcd9b8f0..db5338f39 100644 --- a/test/integration/entry-integration.test.ts +++ b/test/integration/entry-integration.test.ts @@ -45,6 +45,74 @@ describe('Entry Api', () => { expect(response.fields, 'fields').to.be.ok }) }) + + describe('Gets entries with cursor pagination', () => { + test('gets entries with cursor pagination with items', async () => { + const response = await environment.getEntriesWithCursor() + expect(response.items).toBeTruthy() + }) + + test('returns a cursor paginated entry collection when no query is provided', async () => { + const response = await environment.getEntriesWithCursor() + + expect(response.items).not.toHaveLength(0) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Entry') + expect(item.fields).toBeDefined() + }) + }) + + test('returns [limit] number of items', async () => { + const response = await environment.getEntriesWithCursor({ limit: 3 }) + + expect(response.items).toHaveLength(3) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Entry') + expect(item.fields).toBeDefined() + }) + }) + + test('supports forward pagination', async () => { + const firstPage = await environment.getEntriesWithCursor({ limit: 2 }) + const secondPage = await environment.getEntriesWithCursor({ + limit: 2, + pageNext: firstPage?.pages?.next, + }) + + expect(secondPage.items).toHaveLength(2) + expect(firstPage.items[0].sys.id).not.toEqual(secondPage.items[0].sys.id) + }) + + test('should support backward pagination', async () => { + const firstPage = await environment.getEntriesWithCursor({ + limit: 2, + order: ['sys.createdAt'], + }) + const secondPage = await environment.getEntriesWithCursor({ + limit: 2, + pageNext: firstPage?.pages?.next, + order: ['sys.createdAt'], + }) + const result = await environment.getEntriesWithCursor({ + limit: 2, + pagePrev: secondPage?.pages?.prev, + order: ['sys.createdAt'], + }) + + expect(result.items).toHaveLength(2) + + firstPage.items.forEach((item, index) => { + expect(item.sys.id).equal(result.items[index].sys.id) + }) + }) + }) + test('Gets published entries', async () => { return environment.getPublishedEntries().then((response) => { expect(response.items[0].sys.firstPublishedAt).to.not.be.undefined diff --git a/test/unit/create-environment-api.test.ts b/test/unit/create-environment-api.test.ts index df3bf0745..ef1770275 100644 --- a/test/unit/create-environment-api.test.ts +++ b/test/unit/create-environment-api.test.ts @@ -18,6 +18,7 @@ import { extensionMock, functionCollectionMock, functionLogMock, + mockCursorPaginatedCollection, } from './mocks/entities' import { describe, test, expect } from 'vitest' import { toPlainObject } from 'contentful-sdk-core' @@ -27,14 +28,16 @@ import { makeEntityMethodFailingTest, makeGetCollectionTest, makeGetEntityTest, + makeGetPaginatedCollectionTest, testGettingEntrySDKObject, } from './test-creators/static-entity-methods' -import { wrapEntry } from '../../lib/entities/entry' -import { wrapAsset } from '../../lib/entities/asset' +import { EntryProps, wrapEntry } from '../../lib/entities/entry' +import { AssetProps, wrapAsset } from '../../lib/entities/asset' import { wrapTagCollection } from '../../lib/entities/tag' import setupMakeRequest from './mocks/makeRequest' import createEnvironmentApi from '../../lib/create-environment-api' import { AppActionCallRawResponseProps } from '../../lib/entities/app-action-call' +import { ContentTypeProps } from '../../lib/entities/content-type' function setup(promise: Promise) { const entitiesMock = setupEntitiesMock() @@ -131,6 +134,20 @@ describe('A createEnvironmentApi', () => { }) }) + test('API call getContentTypesWithCursor', async () => { + return makeGetPaginatedCollectionTest(setup, { + entityType: 'contentType', + mockToReturn: mockCursorPaginatedCollection(contentTypeMock), + methodToTest: 'getContentTypesWithCursor', + }) + }) + + test('API call getContentTypesWithCursor fails', async () => { + return makeEntityMethodFailingTest(setup, { + methodToTest: 'getContentTypesWithCursor', + }) + }) + test('API call getBulkAction', async () => { return makeGetEntityTest(setup, { entityType: 'bulkAction', @@ -256,6 +273,20 @@ describe('A createEnvironmentApi', () => { }) }) + test('API call getEntriesWithCursor', async () => { + return makeGetPaginatedCollectionTest(setup, { + entityType: 'entry', + mockToReturn: mockCursorPaginatedCollection(entryMock), + methodToTest: 'getEntriesWithCursor', + }) + }) + + test('API call getEntriesWithCursor fails', async () => { + return makeEntityMethodFailingTest(setup, { + methodToTest: 'getEntriesWithCursor', + }) + }) + test('API call createEntry', async () => { const { api, makeRequest, entitiesMock } = setup(Promise.resolve(entryMock)) entitiesMock.entry.wrapEntry.mockReturnValue(entryMock) @@ -328,6 +359,20 @@ describe('A createEnvironmentApi', () => { }) }) + test('API call getAssetsWithCursor', async () => { + return makeGetPaginatedCollectionTest(setup, { + entityType: 'asset', + mockToReturn: mockCursorPaginatedCollection(assetMock), + methodToTest: 'getAssetsWithCursor', + }) + }) + + test('API call getAssetsWithCursor fails', async () => { + return makeEntityMethodFailingTest(setup, { + methodToTest: 'getAssetsWithCursor', + }) + }) + test('API call createAsset', async () => { return makeCreateEntityTest(setup, { entityType: 'asset', diff --git a/test/unit/mocks/entities.ts b/test/unit/mocks/entities.ts index 42478f1fa..48ad6697a 100644 --- a/test/unit/mocks/entities.ts +++ b/test/unit/mocks/entities.ts @@ -4,7 +4,13 @@ import cloneDeep from 'lodash/cloneDeep' import { makeLink, makeVersionedLink } from '../../utils' import type { ContentFields } from '../../../lib/entities/content-type-fields' import type { AppSigningSecretProps } from '../../../lib/entities/app-signing-secret' -import type { CollectionProp, Link, MetaLinkProps, MetaSysProps } from '../../../lib/common-types' +import type { + CollectionProp, + CursorPaginatedCollectionProp, + Link, + MetaLinkProps, + MetaSysProps, +} from '../../../lib/common-types' import type { AppEventSubscriptionProps } from '../../../lib/entities/app-event-subscription' import type { SpaceProps } from '../../../lib/entities/space' import type { EnvironmentProps } from '../../../lib/entities/environment' @@ -1481,6 +1487,20 @@ function mockCollection(entityMock): CollectionProp { } } +function mockCursorPaginatedCollection(entityMock): CursorPaginatedCollectionProp { + return { + sys: { + type: 'Array', + }, + limit: 100, + items: [entityMock as T], + pages: { + next: undefined, + prev: undefined, + }, + } +} + function setupEntitiesMock() { const entitiesMock = { aiAction: { @@ -1759,6 +1779,7 @@ export { errorMock, cloneMock, mockCollection, + mockCursorPaginatedCollection, setupEntitiesMock, uploadMock, uploadCredentialMock, diff --git a/test/unit/test-creators/static-entity-methods.ts b/test/unit/test-creators/static-entity-methods.ts index 9db9bfb08..415d2af1b 100644 --- a/test/unit/test-creators/static-entity-methods.ts +++ b/test/unit/test-creators/static-entity-methods.ts @@ -28,6 +28,25 @@ export async function makeGetCollectionTest(setup, { entityType, mockToReturn, m }) } +export async function makeGetPaginatedCollectionTest( + setup, + { entityType, mockToReturn, methodToTest }, +) { + await makeGetEntityTest(setup, { + entityType: entityType, + mockToReturn: { + limit: 100, + items: [mockToReturn], + pages: { + next: undefined, + prev: undefined, + }, + }, + methodToTest: methodToTest, + wrapperSuffix: 'Collection', + }) +} + export async function makeEntityMethodFailingTest(setup, { methodToTest }) { const error = cloneMock('error') const { api } = setup(Promise.reject(error))