Skip to content

Commit 9b64b89

Browse files
authored
feat: enable timeline preview [TOL-3117] (#2525)
1 parent a03d605 commit 9b64b89

File tree

9 files changed

+238
-66
lines changed

9 files changed

+238
-66
lines changed

README.md

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
<h3 align="center">Javascript</h3>
1212

1313
<p align="center">
14-
<a href="README.md">Readme</a> ·
15-
<a href="MIGRATION.md">Migration</a> ·
16-
<a href="ADVANCED.md">Advanced</a> ·
17-
<a href="TYPESCRIPT.md">TypeScript</a> ·
14+
<a href="README.md">Readme</a> ·
15+
<a href="MIGRATION.md">Migration</a> ·
16+
<a href="ADVANCED.md">Advanced</a> ·
17+
<a href="TYPESCRIPT.md">TypeScript</a> ·
1818
<a href="CONTRIBUTING.md">Contributing</a>
1919
</p>
2020

@@ -70,8 +70,8 @@ JavaScript library for the Contentful [Content Delivery API](https://www.content
7070
- [Authentication](#authentication)
7171
- [Documentation \& References](#documentation--references)
7272
- [Configuration](#configuration)
73-
- [Request configuration options](#request-configuration-options)
74-
- [Response configuration options](#response-configuration-options)
73+
- [Request configuration options](#request-configuration-options)
74+
- [Response configuration options](#response-configuration-options)
7575
- [Client chain modifiers](#client-chain-modifiers)
7676
- [Entries](#entries)
7777
- [Example](#example)
@@ -186,7 +186,7 @@ Check the [releases](https://github.com/contentful/contentful.js/releases) page
186186
The following code snippet is the most basic one you can use to get some content from Contentful with this library:
187187

188188
```js
189-
import * as contentful from "contentful"
189+
import * as contentful from 'contentful'
190190
const client = contentful.createClient({
191191
// This is the space ID. A space is like a project folder in Contentful terms
192192
space: 'developer_bookshelf',
@@ -207,7 +207,7 @@ Check out this [JSFiddle](https://jsfiddle.net/contentful/kefaj4s8/) version of
207207
This library can also be used with the Preview API. In order to do so, you need to use the Preview API Access token, available on the same page where you get the Delivery API token, and specify the host of the preview API, such as:
208208

209209
```js
210-
import * as contentful from "contentful"
210+
import * as contentful from 'contentful'
211211
const client = contentful.createClient({
212212
space: 'developer_bookshelf',
213213
accessToken: 'preview_0b7f6x59a0',
@@ -279,6 +279,28 @@ The configuration options belong to two categories: request config and response
279279

280280
> :warning: **Response config options** have been **removed** in `v10.0.0` in favor of the new [client chain modifiers](#client-chain-modifiers) approach.
281281
282+
### Timeline Preview
283+
284+
The Timeline Preview API provides the ability to query content by future date or specific release
285+
286+
##### Example
287+
288+
```js
289+
import * as contentful from 'contentful'
290+
const client = contentful.createClient({
291+
space: 'developer_bookshelf',
292+
accessToken: 'preview_0b7f6x59a0',
293+
host: 'preview.contentful.com',
294+
// either release or timestamp or both can be passed as a valid config
295+
alphaFeatures: {
296+
timelinePreview: {
297+
release: { lte: 'black-friday' },
298+
timestamp: { lte: '2025-11-29T08:46:15Z' },
299+
},
300+
},
301+
})
302+
```
303+
282304
### Client chain modifiers
283305

284306
> Introduced in `v10.0.0`.
@@ -332,24 +354,18 @@ const localizedData = {
332354
{
333355
metadata: { tags: [] },
334356
sys: {
335-
space: {
336-
sys: { type: 'Link', linkType: 'Space', id: 'my-space-id' },
337-
},
357+
space: { sys: { type: 'Link', linkType: 'Space', id: 'my-space-id' } },
338358
id: 'my-zoo',
339359
type: 'Entry',
340360
createdAt: '2020-01-01T00:00:00.000Z',
341361
updatedAt: '2020-01-01T00:00:00.000Z',
342-
environment: {
343-
sys: { id: 'master', type: 'Link', linkType: 'Environment' },
344-
},
362+
environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } },
345363
revision: 1,
346364
contentType: { sys: { type: 'Link', linkType: 'ContentType', id: 'zoo' } },
347365
locale: 'en-US',
348366
},
349367
fields: {
350-
animal: {
351-
'en-US': { sys: { type: 'Link', linkType: 'Entry', id: 'oink' } },
352-
},
368+
animal: { 'en-US': { sys: { type: 'Link', linkType: 'Entry', id: 'oink' } } },
353369
anotheranimal: {
354370
'en-US': { sys: { type: 'Link', linkType: 'Entry', id: 'middle-parrot' } },
355371
},
@@ -361,28 +377,19 @@ const localizedData = {
361377
{
362378
metadata: { tags: [] },
363379
sys: {
364-
space: {
365-
sys: { type: 'Link', linkType: 'Space', id: 'my-space-id' },
366-
},
380+
space: { sys: { type: 'Link', linkType: 'Space', id: 'my-space-id' } },
367381
id: 'oink',
368382
type: 'Entry',
369383
createdAt: '2020-01-01T00:00:00.000Z',
370384
updatedAt: '2020-02-01T00:00:00.000Z',
371-
environment: {
372-
sys: { id: 'master', type: 'Link', linkType: 'Environment' },
373-
},
385+
environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } },
374386
revision: 2,
375387
contentType: { sys: { type: 'Link', linkType: 'ContentType', id: 'animal' } },
376388
locale: 'en-US',
377389
},
378390
fields: {
379-
name: {
380-
'en-US': 'Pig',
381-
de: 'Schwein',
382-
},
383-
friend: {
384-
'en-US': { sys: { type: 'Link', linkType: 'Entry', id: 'groundhog' } },
385-
},
391+
name: { 'en-US': 'Pig', de: 'Schwein' },
392+
friend: { 'en-US': { sys: { type: 'Link', linkType: 'Entry', id: 'groundhog' } } },
386393
},
387394
},
388395
],

lib/contentful.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
validateResolveLinksParam,
1515
} from './utils/validate-params.js'
1616
import type { ContentfulClientApi } from './types/client.js'
17+
import type { TimelinePreview } from './types/timeline-preview.js'
1718

1819
/**
1920
* @category Client
@@ -119,6 +120,7 @@ export interface CreateClientParams {
119120
* This feature is only available when using the Content Preview API.
120121
*/
121122
includeContentSourceMaps?: boolean
123+
122124
/**
123125
* Enable alpha features.
124126
*/
@@ -127,6 +129,13 @@ export interface CreateClientParams {
127129
* @deprecated Use the `includeContentSourceMaps` option directly instead.
128130
*/
129131
includeContentSourceMaps?: boolean
132+
133+
/**
134+
* Enable Timeline Preview.
135+
* @remarks
136+
* This feature is only available in private beta when using the Content Preview API.
137+
*/
138+
timelinePreview?: TimelinePreview
130139
}
131140
}
132141

lib/create-contentful-api.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
validateResolveLinksParam,
4444
} from './utils/validate-params.js'
4545
import validateSearchParameters from './utils/validate-search-parameters.js'
46+
import { getTimelinePreviewParams } from './utils/timeline-preview-helpers.js'
4647

4748
const ASSET_KEY_MAX_LIFETIME = 48 * 60 * 60
4849

@@ -130,6 +131,27 @@ export default function createContentfulApi<OptionType extends ChainOptions>(
130131
return query
131132
}
132133

134+
function maybeEnableTimelinePreview(path: string): string {
135+
const { enabled } = getTimelinePreviewParams(http.httpClientParams as CreateClientParams)
136+
return enabled ? `timeline/${path}` : path
137+
}
138+
139+
function maybeAddTimelinePreviewConfigToQuery(query: Record<string, any>) {
140+
const { enabled, timelinePreview } = getTimelinePreviewParams(
141+
http.httpClientParams as CreateClientParams,
142+
)
143+
if (enabled) {
144+
if (timelinePreview?.release) {
145+
query.release = timelinePreview.release
146+
}
147+
if (timelinePreview?.timestamp) {
148+
query.timestamp = timelinePreview.timestamp
149+
}
150+
}
151+
152+
return query
153+
}
154+
133155
function maybeEncodeCPAResponse(data: any, config: Record<string, any>): any {
134156
const includeContentSourceMaps = config?.params?.includeContentSourceMaps as boolean
135157

@@ -260,6 +282,13 @@ export default function createContentfulApi<OptionType extends ChainOptions>(
260282
)
261283
}
262284

285+
function prepareQuery(query: Record<string, any>): Record<string, any> {
286+
// First, add timeline preview config if enabled
287+
const withTimelinePreview = maybeAddTimelinePreviewConfigToQuery({ ...query })
288+
// Then, apply source maps and other normalizations
289+
return maybeEnableSourceMaps(normalizeSearchParameters(normalizeSelect(withTimelinePreview)))
290+
}
291+
263292
async function internalGetEntries<
264293
EntrySkeleton extends EntrySkeletonType,
265294
Locales extends LocaleCode,
@@ -269,13 +298,12 @@ export default function createContentfulApi<OptionType extends ChainOptions>(
269298
options: Options,
270299
): Promise<EntryCollection<EntrySkeleton, ModifiersFromOptions<Options>, Locales>> {
271300
const { withoutLinkResolution, withoutUnresolvableLinks } = options
272-
273301
try {
274302
const entries = await get({
275303
context: 'environment',
276-
path: 'entries',
304+
path: maybeEnableTimelinePreview('entries'),
277305
config: createRequestConfig({
278-
query: maybeEnableSourceMaps(normalizeSearchParameters(normalizeSelect(query))),
306+
query: prepareQuery(query),
279307
}),
280308
})
281309

@@ -321,8 +349,8 @@ export default function createContentfulApi<OptionType extends ChainOptions>(
321349
try {
322350
return get({
323351
context: 'environment',
324-
path: `assets/${id}`,
325-
config: createRequestConfig({ query: maybeEnableSourceMaps(normalizeSelect(query)) }),
352+
path: maybeEnableTimelinePreview(`assets/${id}`),
353+
config: createRequestConfig({ query: prepareQuery(query) }),
326354
})
327355
} catch (error) {
328356
errorHandler(error)
@@ -354,9 +382,9 @@ export default function createContentfulApi<OptionType extends ChainOptions>(
354382
try {
355383
return get({
356384
context: 'environment',
357-
path: 'assets',
385+
path: maybeEnableTimelinePreview('assets'),
358386
config: createRequestConfig({
359-
query: maybeEnableSourceMaps(normalizeSearchParameters(normalizeSelect(query))),
387+
query: prepareQuery(query),
360388
}),
361389
})
362390
} catch (error) {

lib/types/timeline-preview.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type TimelinePreview = {
2+
release?: { lte: string }
3+
timestamp?: { lte: string | Date }
4+
} & ({ release: { lte: string } } | { timestamp: { lte: string | Date } })

lib/utils/timeline-preview-helpers.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { CreateClientParams } from '../contentful'
2+
import type { TimelinePreview } from '../types/timeline-preview'
3+
import { checkEnableTimelinePreviewIsAllowed } from './validate-params'
4+
import { ValidationError } from './validation-error'
5+
6+
function isValidRelease(release: TimelinePreview['release']): boolean {
7+
return !!(release && typeof release === 'object' && typeof release.lte === 'string')
8+
}
9+
10+
function isValidTimestamp(timestamp: TimelinePreview['timestamp']): boolean {
11+
return !!(
12+
timestamp &&
13+
typeof timestamp === 'object' &&
14+
(typeof timestamp.lte === 'string' || timestamp.lte instanceof Date)
15+
)
16+
}
17+
18+
export const isValidTimelinePreviewConfig = (timelinePreview: TimelinePreview) => {
19+
if (
20+
typeof timelinePreview !== 'object' ||
21+
timelinePreview === null ||
22+
Array.isArray(timelinePreview)
23+
) {
24+
throw new ValidationError(
25+
'timelinePreview',
26+
`The 'timelinePreview' parameter must be an object.`,
27+
)
28+
}
29+
30+
const hasRelease = isValidRelease(timelinePreview.release)
31+
const hasTimestamp = isValidTimestamp(timelinePreview.timestamp)
32+
33+
if (!hasRelease && !hasTimestamp) {
34+
throw new ValidationError(
35+
'timelinePreview',
36+
`The 'timelinePreview' object must have at least one of 'release' or 'timestamp' with a valid 'lte' property.`,
37+
)
38+
}
39+
40+
return hasRelease || hasTimestamp
41+
}
42+
43+
export const getTimelinePreviewParams = (params: CreateClientParams) => {
44+
const host = params?.host as string
45+
const timelinePreview = params?.alphaFeatures?.timelinePreview as TimelinePreview
46+
const enabled = checkEnableTimelinePreviewIsAllowed(host, timelinePreview)
47+
return { enabled, timelinePreview }
48+
}

lib/utils/validate-params.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { TimelinePreview } from '../types/timeline-preview.js'
2+
import { isValidTimelinePreviewConfig } from './timeline-preview-helpers.js'
13
import { ValidationError } from './validation-error.js'
24

35
function checkLocaleParamIsAll(query) {
@@ -74,3 +76,26 @@ export function checkIncludeContentSourceMapsParamIsAllowed(
7476

7577
return includeContentSourceMaps as boolean
7678
}
79+
80+
export function checkEnableTimelinePreviewIsAllowed(
81+
host: string,
82+
timelinePreview?: TimelinePreview,
83+
) {
84+
if (timelinePreview === undefined) {
85+
return false
86+
}
87+
88+
const isValidConfig = isValidTimelinePreviewConfig(timelinePreview)
89+
90+
const isValidHost = typeof host === 'string' && host.startsWith('preview')
91+
92+
if (isValidConfig && !isValidHost) {
93+
throw new ValidationError(
94+
'timelinePreview',
95+
`The 'timelinePreview' parameter can only be used with the CPA. Please set host to 'preview.contentful.com' to enable Timeline Preview.
96+
`,
97+
)
98+
}
99+
100+
return true
101+
}

test/integration/getEntry.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,18 @@ test('Gets entry with attached metadata and field called "metadata" on preview',
353353
expect(response.fields.metadata).toBeDefined()
354354
expect(response.metadata).toBeDefined()
355355
})
356+
357+
test('can make calls to TimelinePreview API on preview', async () => {
358+
const timelinePreviewClient = contentful.createClient({
359+
...previewParamsWithCSM,
360+
alphaFeatures: {
361+
timelinePreview: { release: { lte: 'black-friday' } },
362+
},
363+
})
364+
365+
const entryWithMetadataFieldAndMetadata = '1NnAC4eF9IRMpHtFB1NleW'
366+
367+
await expect(timelinePreviewClient.getEntry(entryWithMetadataFieldAndMetadata)).rejects.toThrow(
368+
/spaces\/ezs1swce23xe\/environments\/master\/timeline\/entries/,
369+
)
370+
})

0 commit comments

Comments
 (0)