@teleporthq/teleport-plugin-next-data-source 0.43.57 → 0.43.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/_helpers/pagination-widget-fixture.ts +230 -0
- package/__tests__/ecommerce-product-allow-backorders.test.ts +156 -0
- package/__tests__/ecommerce-product-discounts.test.ts +196 -0
- package/__tests__/ecommerce-product-model.test.ts +179 -0
- package/__tests__/infinite-scroll.test.ts +143 -0
- package/__tests__/numbered-pagination.test.ts +113 -0
- package/__tests__/page-reset-on-filter-sort.test.ts +182 -0
- package/__tests__/page-url-sync.test.ts +208 -0
- package/__tests__/product-price-sort.ts +313 -0
- package/__tests__/search-url-sync.test.ts +7 -3
- package/dist/cjs/array-mapper-pagination.d.ts +9 -0
- package/dist/cjs/array-mapper-pagination.d.ts.map +1 -1
- package/dist/cjs/array-mapper-pagination.js.map +1 -1
- package/dist/cjs/count-effect.d.ts +42 -0
- package/dist/cjs/count-effect.d.ts.map +1 -0
- package/dist/cjs/count-effect.js +81 -0
- package/dist/cjs/count-effect.js.map +1 -0
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +2 -1
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +3 -2
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/infinite-scroll.d.ts +65 -0
- package/dist/cjs/infinite-scroll.d.ts.map +1 -0
- package/dist/cjs/infinite-scroll.js +190 -0
- package/dist/cjs/infinite-scroll.js.map +1 -0
- package/dist/cjs/numbered-pagination.d.ts +42 -0
- package/dist/cjs/numbered-pagination.d.ts.map +1 -0
- package/dist/cjs/numbered-pagination.js +167 -0
- package/dist/cjs/numbered-pagination.js.map +1 -0
- package/dist/cjs/page-reset.d.ts +42 -0
- package/dist/cjs/page-reset.d.ts.map +1 -0
- package/dist/cjs/page-reset.js +116 -0
- package/dist/cjs/page-reset.js.map +1 -0
- package/dist/cjs/page-url-sync.d.ts +42 -0
- package/dist/cjs/page-url-sync.d.ts.map +1 -0
- package/dist/cjs/page-url-sync.js +180 -0
- package/dist/cjs/page-url-sync.js.map +1 -0
- package/dist/cjs/pagination-controls.d.ts +55 -0
- package/dist/cjs/pagination-controls.d.ts.map +1 -0
- package/dist/cjs/pagination-controls.js +159 -0
- package/dist/cjs/pagination-controls.js.map +1 -0
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +506 -160
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/product-price-sort.d.ts +116 -0
- package/dist/cjs/product-price-sort.d.ts.map +1 -0
- package/dist/cjs/product-price-sort.js +169 -0
- package/dist/cjs/product-price-sort.js.map +1 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts +12 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +2 -1
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +2 -1
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +17 -1
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
- package/dist/cjs/transformations/shared-utils.js +1 -1
- package/dist/cjs/transformations/shared-utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/array-mapper-pagination.d.ts +9 -0
- package/dist/esm/array-mapper-pagination.d.ts.map +1 -1
- package/dist/esm/array-mapper-pagination.js.map +1 -1
- package/dist/esm/count-effect.d.ts +42 -0
- package/dist/esm/count-effect.d.ts.map +1 -0
- package/dist/esm/count-effect.js +53 -0
- package/dist/esm/count-effect.js.map +1 -0
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +2 -1
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +3 -2
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/infinite-scroll.d.ts +65 -0
- package/dist/esm/infinite-scroll.d.ts.map +1 -0
- package/dist/esm/infinite-scroll.js +159 -0
- package/dist/esm/infinite-scroll.js.map +1 -0
- package/dist/esm/numbered-pagination.d.ts +42 -0
- package/dist/esm/numbered-pagination.d.ts.map +1 -0
- package/dist/esm/numbered-pagination.js +138 -0
- package/dist/esm/numbered-pagination.js.map +1 -0
- package/dist/esm/page-reset.d.ts +42 -0
- package/dist/esm/page-reset.d.ts.map +1 -0
- package/dist/esm/page-reset.js +87 -0
- package/dist/esm/page-reset.js.map +1 -0
- package/dist/esm/page-url-sync.d.ts +42 -0
- package/dist/esm/page-url-sync.d.ts.map +1 -0
- package/dist/esm/page-url-sync.js +152 -0
- package/dist/esm/page-url-sync.js.map +1 -0
- package/dist/esm/pagination-controls.d.ts +55 -0
- package/dist/esm/pagination-controls.d.ts.map +1 -0
- package/dist/esm/pagination-controls.js +126 -0
- package/dist/esm/pagination-controls.js.map +1 -0
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +507 -161
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/product-price-sort.d.ts +116 -0
- package/dist/esm/product-price-sort.d.ts.map +1 -0
- package/dist/esm/product-price-sort.js +160 -0
- package/dist/esm/product-price-sort.js.map +1 -0
- package/dist/esm/transformations/ecommerce-product.d.ts +12 -0
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +3 -2
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +2 -1
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +17 -1
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
- package/dist/esm/transformations/shared-utils.js +1 -1
- package/dist/esm/transformations/shared-utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/array-mapper-pagination.ts +10 -0
- package/src/count-effect.ts +137 -0
- package/src/fetchers/postgresql.ts +52 -13
- package/src/fetchers/teleport.ts +53 -13
- package/src/infinite-scroll.ts +401 -0
- package/src/numbered-pagination.ts +292 -0
- package/src/page-reset.ts +162 -0
- package/src/page-url-sync.ts +241 -0
- package/src/pagination-controls.ts +176 -0
- package/src/pagination-plugin.ts +755 -346
- package/src/product-price-sort.ts +204 -0
- package/src/transformations/ecommerce-product.ts +141 -10
- package/src/transformations/index.ts +19 -1
- package/src/transformations/shared-utils.ts +14 -0
- package/dist/cjs/array-mapper-registry.d.ts +0 -85
- package/dist/cjs/array-mapper-registry.d.ts.map +0 -1
- package/dist/cjs/array-mapper-registry.js +0 -296
- package/dist/cjs/array-mapper-registry.js.map +0 -1
- package/dist/cjs/pagination-with-count.d.ts +0 -6
- package/dist/cjs/pagination-with-count.d.ts.map +0 -1
- package/dist/cjs/pagination-with-count.js +0 -63
- package/dist/cjs/pagination-with-count.js.map +0 -1
- package/dist/esm/array-mapper-registry.d.ts +0 -85
- package/dist/esm/array-mapper-registry.d.ts.map +0 -1
- package/dist/esm/array-mapper-registry.js +0 -292
- package/dist/esm/array-mapper-registry.js.map +0 -1
- package/dist/esm/pagination-with-count.d.ts +0 -6
- package/dist/esm/pagination-with-count.d.ts.map +0 -1
- package/dist/esm/pagination-with-count.js +0 -34
- package/dist/esm/pagination-with-count.js.map +0 -1
- package/src/array-mapper-registry.ts +0 -416
- package/src/pagination-with-count.ts +0 -89
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
|
|
4
|
+
// A product's optional 3D model. The transform must hand the storefront
|
|
5
|
+
// resolved URLs, STRING gates ('true' / 'false' — the builders compare with
|
|
6
|
+
// `=`/`!=`, so an absent field reads as the IMAGE branch), and the gallery as
|
|
7
|
+
// media entries in display order. MUST stay in sync with the renderer mirror
|
|
8
|
+
// (packages/renderer/src/utils/ecommerce-products.ts) and the GUI's
|
|
9
|
+
// `PRODUCT_PROP_DEFAULT` — see the GUI-side specs.
|
|
10
|
+
|
|
11
|
+
type Product = Record<string, unknown> & {
|
|
12
|
+
galleryMedia: Array<{ kind: string; src: string; poster: string; thumbnail: string; alt: string }>
|
|
13
|
+
galleryThumbnails: Array<{ kind: string; src: string }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const evalBuildProduct = (): ((record: unknown, options?: unknown) => Product) => {
|
|
17
|
+
const code =
|
|
18
|
+
generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode()
|
|
19
|
+
const fn = new Function(code + '\nreturn buildEcommerceProduct;')
|
|
20
|
+
return fn() as (record: unknown, options?: unknown) => Product
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ASSET_MAP = {
|
|
24
|
+
TQ_model: { remoteSrc: 'https://storage.example.com/p/model.glb' },
|
|
25
|
+
TQ_poster: { remoteSrc: 'https://cdn.example.com/poster.png' },
|
|
26
|
+
TQ_img1: { remoteSrc: 'https://cdn.example.com/img1.jpg' },
|
|
27
|
+
TQ_img2: { remoteSrc: 'https://cdn.example.com/img2.jpg' },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const baseRecord = {
|
|
31
|
+
id: 'p1',
|
|
32
|
+
name: 'Chair',
|
|
33
|
+
slug: 'chair',
|
|
34
|
+
price: '10',
|
|
35
|
+
currency: 'USD',
|
|
36
|
+
image_url: 'TQ_img1',
|
|
37
|
+
gallery_images: JSON.stringify(['TQ_img2']),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('ecommerce product transform — 3D model', () => {
|
|
41
|
+
const buildEcommerceProduct = evalBuildProduct()
|
|
42
|
+
const build = (record: Record<string, unknown>) =>
|
|
43
|
+
buildEcommerceProduct(record, { assetMap: ASSET_MAP })
|
|
44
|
+
|
|
45
|
+
it('emits the image branch for a product without a model', () => {
|
|
46
|
+
const product = build(baseRecord)
|
|
47
|
+
expect(product.modelUrl).toBeNull()
|
|
48
|
+
expect(product.modelPoster).toBeNull()
|
|
49
|
+
expect(product.hasModel).toBe('false')
|
|
50
|
+
expect(product.showModel).toBe('false')
|
|
51
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image'])
|
|
52
|
+
expect(product.galleryThumbnails.map((m) => m.src)).toEqual([ASSET_MAP.TQ_img2.remoteSrc])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('resolves the model and poster asset ids to URLs and shows the model by default', () => {
|
|
56
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', model_poster_url: 'TQ_poster' })
|
|
57
|
+
expect(product.modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
58
|
+
expect(product.modelPoster).toBe(ASSET_MAP.TQ_poster.remoteSrc)
|
|
59
|
+
expect(product.hasModel).toBe('true')
|
|
60
|
+
// NULL / absent flag = ON
|
|
61
|
+
expect(product.showModel).toBe('true')
|
|
62
|
+
// hero first, then the images
|
|
63
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['model', 'image', 'image'])
|
|
64
|
+
expect(product.galleryMedia[0]).toEqual({
|
|
65
|
+
kind: 'model',
|
|
66
|
+
src: ASSET_MAP.TQ_model.remoteSrc,
|
|
67
|
+
poster: ASSET_MAP.TQ_poster.remoteSrc,
|
|
68
|
+
thumbnail: ASSET_MAP.TQ_poster.remoteSrc,
|
|
69
|
+
alt: 'Chair',
|
|
70
|
+
})
|
|
71
|
+
// the main image moves into the strip when the model is the hero
|
|
72
|
+
expect(product.galleryThumbnails.map((m) => m.src)).toEqual([
|
|
73
|
+
ASSET_MAP.TQ_img1.remoteSrc,
|
|
74
|
+
ASSET_MAP.TQ_img2.remoteSrc,
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps the image hero when the merchant switched the model off, with the model last', () => {
|
|
79
|
+
const product = build({
|
|
80
|
+
...baseRecord,
|
|
81
|
+
model_url: 'TQ_model',
|
|
82
|
+
model_poster_url: 'TQ_poster',
|
|
83
|
+
show_model_by_default: false,
|
|
84
|
+
})
|
|
85
|
+
expect(product.hasModel).toBe('true')
|
|
86
|
+
expect(product.showModel).toBe('false')
|
|
87
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image', 'model'])
|
|
88
|
+
expect(product.galleryThumbnails.map((m) => m.kind)).toEqual(['image', 'model'])
|
|
89
|
+
expect(product.galleryThumbnails[1].src).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
[true, 'true'],
|
|
94
|
+
[false, 'false'],
|
|
95
|
+
[1, 'true'],
|
|
96
|
+
[0, 'false'],
|
|
97
|
+
['true', 'true'],
|
|
98
|
+
['false', 'false'],
|
|
99
|
+
['t', 'true'],
|
|
100
|
+
['f', 'false'],
|
|
101
|
+
['1', 'true'],
|
|
102
|
+
['0', 'false'],
|
|
103
|
+
[null, 'true'],
|
|
104
|
+
[undefined, 'true'],
|
|
105
|
+
['', 'true'],
|
|
106
|
+
])('normalizes show_model_by_default = %p to showModel %s', (raw, expected) => {
|
|
107
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', show_model_by_default: raw })
|
|
108
|
+
expect(product.showModel).toBe(expected)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('always shows the model when it is the only media, whatever the flag says', () => {
|
|
112
|
+
const product = build({
|
|
113
|
+
id: 'p2',
|
|
114
|
+
name: 'Lamp',
|
|
115
|
+
slug: 'lamp',
|
|
116
|
+
price: '5',
|
|
117
|
+
currency: 'USD',
|
|
118
|
+
model_url: 'TQ_model',
|
|
119
|
+
show_model_by_default: false,
|
|
120
|
+
})
|
|
121
|
+
expect(product.showModel).toBe('true')
|
|
122
|
+
expect(product.galleryMedia).toHaveLength(1)
|
|
123
|
+
expect(product.galleryThumbnails).toEqual([])
|
|
124
|
+
// nothing to poster with — the viewer paints its transparent poster
|
|
125
|
+
expect(product.modelPoster).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('falls back to the main image as poster and thumbnail when the model has none', () => {
|
|
129
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', show_model_by_default: false })
|
|
130
|
+
expect(product.modelPoster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
131
|
+
const modelEntry = product.galleryMedia.find((m) => m.kind === 'model')
|
|
132
|
+
expect(modelEntry?.poster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
133
|
+
expect(modelEntry?.thumbnail).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('treats an asset id the asset map cannot resolve as no model at all', () => {
|
|
137
|
+
// resolveAssetUrl returns unknown ids verbatim; a bare 'TQ_…' must never
|
|
138
|
+
// reach <model-viewer src>.
|
|
139
|
+
const product = build({ ...baseRecord, model_url: 'TQ_deleted', model_poster_url: 'TQ_gone' })
|
|
140
|
+
expect(product.modelUrl).toBeNull()
|
|
141
|
+
expect(product.hasModel).toBe('false')
|
|
142
|
+
expect(product.showModel).toBe('false')
|
|
143
|
+
expect(product.galleryMedia.map((m) => m.kind)).toEqual(['image', 'image'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('drops a stale poster id but keeps the model', () => {
|
|
147
|
+
const product = build({ ...baseRecord, model_url: 'TQ_model', model_poster_url: 'TQ_gone' })
|
|
148
|
+
expect(product.modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
149
|
+
expect(product.modelPoster).toBe(ASSET_MAP.TQ_img1.remoteSrc)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('accepts raw URLs in the model columns', () => {
|
|
153
|
+
const product = build({
|
|
154
|
+
...baseRecord,
|
|
155
|
+
model_url: 'https://cdn.example.com/raw.glb',
|
|
156
|
+
model_poster_url: '/local/poster.png',
|
|
157
|
+
})
|
|
158
|
+
expect(product.modelUrl).toBe('https://cdn.example.com/raw.glb')
|
|
159
|
+
expect(product.modelPoster).toBe('/local/poster.png')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('carries the model fields into related products', () => {
|
|
163
|
+
const related = { ...baseRecord, id: 'p9', slug: 'chair-9', model_url: 'TQ_model' }
|
|
164
|
+
const product = buildEcommerceProduct(
|
|
165
|
+
{ ...baseRecord, related_product_ids: JSON.stringify(['p9']) },
|
|
166
|
+
{ assetMap: ASSET_MAP, relatedProductsById: { p9: related } }
|
|
167
|
+
)
|
|
168
|
+
const relatedProducts = product.relatedProducts as Product[]
|
|
169
|
+
expect(relatedProducts).toHaveLength(1)
|
|
170
|
+
expect(relatedProducts[0].showModel).toBe('true')
|
|
171
|
+
expect(relatedProducts[0].modelUrl).toBe(ASSET_MAP.TQ_model.remoteSrc)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('uses the image alt for pictures and the product name for the model', () => {
|
|
175
|
+
const product = build({ ...baseRecord, image_alt: 'A chair', model_url: 'TQ_model' })
|
|
176
|
+
expect(product.galleryMedia[0].alt).toBe('Chair')
|
|
177
|
+
expect(product.galleryMedia[1].alt).toBe('A chair')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as types from '@babel/types'
|
|
2
|
+
import generator from '@babel/generator'
|
|
3
|
+
import { ComponentStructure } from '@teleporthq/teleport-types'
|
|
4
|
+
import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
|
|
5
|
+
import {
|
|
6
|
+
makeMultiMapperUidl,
|
|
7
|
+
makeUidlNode,
|
|
8
|
+
makeWidgetComponentChunk,
|
|
9
|
+
type RepeaterOptions,
|
|
10
|
+
} from './_helpers/pagination-widget-fixture'
|
|
11
|
+
|
|
12
|
+
const runPlugin = async (
|
|
13
|
+
repeater: RepeaterOptions,
|
|
14
|
+
controls: string[] = ['previous', 'next']
|
|
15
|
+
): Promise<string> => {
|
|
16
|
+
const chunk = makeWidgetComponentChunk({ controls })
|
|
17
|
+
const structure: ComponentStructure = {
|
|
18
|
+
uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
|
|
19
|
+
chunks: [chunk],
|
|
20
|
+
dependencies: {},
|
|
21
|
+
options: { dataSources: {}, extractedResources: {} },
|
|
22
|
+
} as never
|
|
23
|
+
await createNextArrayMapperPaginationPlugin()(structure)
|
|
24
|
+
return generator(chunk.content as types.Node).code
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const collapse = (code: string): string => code.replace(/\s+/g, ' ')
|
|
28
|
+
|
|
29
|
+
describe('pagination plugin — infinite scroll', () => {
|
|
30
|
+
it('accumulates pages inside fetchData and reports the end of the data', async () => {
|
|
31
|
+
const code = await runPlugin({ infiniteScroll: true })
|
|
32
|
+
const flat = collapse(code)
|
|
33
|
+
|
|
34
|
+
expect(flat).toContain('const ds_0_accumRef = useRef({ sig: "", pages: {} })')
|
|
35
|
+
expect(flat).toContain('const [ds_0_hasMoreFlag, setDs_0_hasMoreFlag] = useState(true)')
|
|
36
|
+
// Two independent end-of-data signals: a short page, and the counted total.
|
|
37
|
+
expect(flat).toContain(
|
|
38
|
+
'const ds_0_hasMore = ds_0_hasMoreFlag && (ds_0_maxPages === 0 || ds_0_page < ds_0_maxPages)'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// Everything but the page identifies the QUERY: a filter, sort or search
|
|
42
|
+
// change must start a fresh list rather than interleave two result sets.
|
|
43
|
+
expect(flat).toContain('const sig = JSON.stringify({ ...params, page: 0 })')
|
|
44
|
+
expect(flat).toContain('if (ds_0_accumRef.current.sig !== sig)')
|
|
45
|
+
expect(flat).toContain('ds_0_accumRef.current = { sig, pages: {} }')
|
|
46
|
+
expect(flat).toContain('ds_0_accumRef.current.pages[page] = rows')
|
|
47
|
+
// A short page is the only end signal a source that cannot count gives.
|
|
48
|
+
expect(flat).toContain('setDs_0_hasMoreFlag(rows.length >= 20)')
|
|
49
|
+
// Numeric sort, so page 10 does not land between 1 and 2.
|
|
50
|
+
expect(flat).toContain('.map(Number).sort((a, b) => a - b).reduce(')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('keeps the page out of the provider key and suppresses initialData', async () => {
|
|
54
|
+
const code = await runPlugin({ infiniteScroll: true })
|
|
55
|
+
|
|
56
|
+
// Remounting per appended page would throw away the accumulated rows...
|
|
57
|
+
expect(code).not.toContain('ds_0_page}`')
|
|
58
|
+
// ...and initialData would make DataProvider skip the fetch that fills the
|
|
59
|
+
// accumulator with page 1.
|
|
60
|
+
expect(code).not.toContain('initialData')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('observes a sentinel after the list when there is no Load More button', async () => {
|
|
64
|
+
const code = await runPlugin({ infiniteScroll: true })
|
|
65
|
+
const flat = collapse(code)
|
|
66
|
+
|
|
67
|
+
expect(flat).toContain('const ds_0_sentinelRef = useRef(null)')
|
|
68
|
+
expect(flat).toContain(
|
|
69
|
+
'<div ref={ds_0_sentinelRef} aria-hidden="true" style={{ height: "1px" }} />'
|
|
70
|
+
)
|
|
71
|
+
expect(flat).toContain('new IntersectionObserver(')
|
|
72
|
+
expect(flat).toContain('typeof IntersectionObserver === "undefined"')
|
|
73
|
+
expect(flat).toContain('rootMargin: "200px"')
|
|
74
|
+
expect(flat).toContain('return () => observer.disconnect()')
|
|
75
|
+
expect(flat).toContain('}, [ds_0_hasMore, ds_0_maxPages])')
|
|
76
|
+
// ⛔ The next page comes from the updater's OWN argument. The effect is
|
|
77
|
+
// re-armed on hasMore/maxPages, neither of which changes on an append while
|
|
78
|
+
// the total is unknown — a captured page would still read 1 on the second
|
|
79
|
+
// intersection and the list would stop growing after one page.
|
|
80
|
+
expect(flat).toContain(
|
|
81
|
+
'setDs_0_page(page => ds_0_maxPages > 0 && page >= ds_0_maxPages ? page : page + 1)'
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('wires the authored Load More button instead, with no observer', async () => {
|
|
86
|
+
const code = await runPlugin({ infiniteScroll: true, infiniteScrollLoadMore: true }, [
|
|
87
|
+
'load-more',
|
|
88
|
+
])
|
|
89
|
+
const flat = collapse(code)
|
|
90
|
+
|
|
91
|
+
expect(flat).toContain('className="thq-load-more-elm"')
|
|
92
|
+
expect(flat).toContain('type="button"')
|
|
93
|
+
expect(flat).toContain('disabled={!ds_0_hasMore}')
|
|
94
|
+
expect(flat).not.toContain('IntersectionObserver')
|
|
95
|
+
expect(flat).not.toContain('sentinelRef')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('drops page controls left in the widget when the list appends', async () => {
|
|
99
|
+
const code = await runPlugin({ infiniteScroll: true, infiniteScrollLoadMore: true }, [
|
|
100
|
+
'previous',
|
|
101
|
+
'load-more',
|
|
102
|
+
'next',
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
// A visitor of an appending list never leaves page 1, so Previous/Next
|
|
106
|
+
// would be dead controls.
|
|
107
|
+
expect(code).not.toContain('thq-previous-elm')
|
|
108
|
+
expect(code).not.toContain('thq-next-elm')
|
|
109
|
+
expect(code).toContain('thq-load-more-elm')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('strips a widget left behind on an auto-loading list', async () => {
|
|
113
|
+
// An older project (or an externally placed widget) can still carry
|
|
114
|
+
// Previous/Next after the author switches to infinite scroll. They would be
|
|
115
|
+
// dead controls, so the mode's own pruning removes them even though this
|
|
116
|
+
// mapper renders no widget of its own.
|
|
117
|
+
const code = await runPlugin({ infiniteScroll: true }, ['previous', 'next'])
|
|
118
|
+
|
|
119
|
+
expect(code).not.toContain('thq-previous-elm')
|
|
120
|
+
expect(code).not.toContain('thq-next-elm')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does not desynchronise the widget zip when mappers use different modes', async () => {
|
|
124
|
+
// ⛔ The infinite mapper draws no container. Counting one for it would wire
|
|
125
|
+
// THIS mapper's buttons to the other mapper's state.
|
|
126
|
+
const chunk = makeWidgetComponentChunk({ controls: ['previous', 'next'], mappers: 1 })
|
|
127
|
+
const structure: ComponentStructure = {
|
|
128
|
+
uidl: {
|
|
129
|
+
name: 'TestComponent',
|
|
130
|
+
node: makeMultiMapperUidl([{ infiniteScroll: true }, {}]),
|
|
131
|
+
},
|
|
132
|
+
chunks: [chunk],
|
|
133
|
+
dependencies: {},
|
|
134
|
+
options: { dataSources: {}, extractedResources: {} },
|
|
135
|
+
} as never
|
|
136
|
+
await createNextArrayMapperPaginationPlugin()(structure)
|
|
137
|
+
const code = generator(chunk.content as types.Node).code
|
|
138
|
+
|
|
139
|
+
// The single authored widget belongs to the SECOND (paginated) mapper.
|
|
140
|
+
expect(code).toContain('setDs_1_page')
|
|
141
|
+
expect(code).not.toContain('setDs_0_page(page => Math.max(1, page - 1))')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as types from '@babel/types'
|
|
2
|
+
import generator from '@babel/generator'
|
|
3
|
+
import { ComponentStructure } from '@teleporthq/teleport-types'
|
|
4
|
+
import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
|
|
5
|
+
import {
|
|
6
|
+
makeUidlNode,
|
|
7
|
+
makeWidgetComponentChunk,
|
|
8
|
+
type RepeaterOptions,
|
|
9
|
+
} from './_helpers/pagination-widget-fixture'
|
|
10
|
+
|
|
11
|
+
const runPlugin = async (
|
|
12
|
+
repeater: RepeaterOptions,
|
|
13
|
+
controls: string[],
|
|
14
|
+
legacyOnly = false
|
|
15
|
+
): Promise<string> => {
|
|
16
|
+
const chunk = makeWidgetComponentChunk({ controls, legacyOnly })
|
|
17
|
+
const structure: ComponentStructure = {
|
|
18
|
+
uidl: { name: 'TestComponent', node: makeUidlNode(repeater) },
|
|
19
|
+
chunks: [chunk],
|
|
20
|
+
dependencies: {},
|
|
21
|
+
options: { dataSources: {}, extractedResources: {} },
|
|
22
|
+
} as never
|
|
23
|
+
await createNextArrayMapperPaginationPlugin()(structure)
|
|
24
|
+
return generator(chunk.content as types.Node).code
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const collapse = (code: string): string => code.replace(/\s+/g, ' ')
|
|
28
|
+
|
|
29
|
+
const NUMBERED_CONTROLS = ['first', 'previous', 'pages', 'next', 'last']
|
|
30
|
+
|
|
31
|
+
describe('pagination plugin — numbered mode', () => {
|
|
32
|
+
it('computes a windowed token list with an ellipsis on each side', async () => {
|
|
33
|
+
const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
|
|
34
|
+
const flat = collapse(code)
|
|
35
|
+
|
|
36
|
+
expect(flat).toContain('const ds_0_pageTokens = useMemo(')
|
|
37
|
+
// No results at all and a single page are answered without a window.
|
|
38
|
+
expect(flat).toContain('if (total <= 0) return []')
|
|
39
|
+
expect(flat).toContain('if (total === 1) return [1]')
|
|
40
|
+
// First and last are always shown, with one neighbour either side.
|
|
41
|
+
expect(flat).toContain('const start = Math.max(2, current - 1)')
|
|
42
|
+
expect(flat).toContain('const end = Math.min(total - 1, current + 1)')
|
|
43
|
+
expect(flat).toContain('if (start > 2) tokens.push("start-ellipsis")')
|
|
44
|
+
expect(flat).toContain('if (end < total - 1) tokens.push("end-ellipsis")')
|
|
45
|
+
expect(flat).toContain('}, [ds_0_maxPages, ds_0_page])')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('repeats the authored template button per page, preserving its class', async () => {
|
|
49
|
+
const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
|
|
50
|
+
const flat = collapse(code)
|
|
51
|
+
|
|
52
|
+
expect(flat).toContain('ds_0_pageTokens.map(token =>')
|
|
53
|
+
expect(flat).toContain('typeof token === "number"')
|
|
54
|
+
// The author's own node is cloned, so their styling survives.
|
|
55
|
+
expect(flat).toContain('className="thq-page-elm"')
|
|
56
|
+
// The current page is MARKED, not disabled — it stays reachable and
|
|
57
|
+
// `[aria-current='page']` is styleable.
|
|
58
|
+
expect(flat).toContain('aria-current={ds_0_page === token ? "page" : undefined}')
|
|
59
|
+
// The `${token}` here is the GENERATED source being asserted on, not an
|
|
60
|
+
// interpolation this file wants performed.
|
|
61
|
+
// tslint:disable-next-line:no-invalid-template-strings
|
|
62
|
+
expect(flat).toContain('key={`page-${token}`}')
|
|
63
|
+
expect(flat).toContain('onClick={() => setDs_0_page(page => page === token ? page : token)}')
|
|
64
|
+
// Whatever label the template carried is replaced by the real number.
|
|
65
|
+
expect(flat).toContain('>{token}<')
|
|
66
|
+
// No authored ellipsis template ⇒ a plain span fills the gap.
|
|
67
|
+
expect(flat).toContain('<span key={token}>…</span>')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('wires First and Last against the live page count', async () => {
|
|
71
|
+
const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
|
|
72
|
+
const flat = collapse(code)
|
|
73
|
+
|
|
74
|
+
expect(flat).toContain('onClick={() => setDs_0_page(page => page === 1 ? page : 1)}')
|
|
75
|
+
expect(flat).toContain('disabled={ds_0_page <= 1}')
|
|
76
|
+
expect(flat).toContain(
|
|
77
|
+
'onClick={() => setDs_0_page(page => page === ds_0_maxPages ? page : ds_0_maxPages)}'
|
|
78
|
+
)
|
|
79
|
+
// maxPages 0 is "no results", where the last page would be page 0.
|
|
80
|
+
expect(flat).toContain('disabled={ds_0_maxPages === 0 || ds_0_page >= ds_0_maxPages}')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('fetches a live count on a PAGE, which otherwise only has the build-time one', async () => {
|
|
84
|
+
const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
|
|
85
|
+
|
|
86
|
+
// A numbered strip labels every page, so a stale build-time count would
|
|
87
|
+
// print numbers that do not exist.
|
|
88
|
+
expect(code).toContain('-count?')
|
|
89
|
+
expect(code).toContain('setDs_0_maxPages(data.count === 0 ? 0 : Math.ceil(data.count / 20))')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('removes a numbered strip left over from a mode switch', async () => {
|
|
93
|
+
// The builder never deletes controls on a mode switch (that would discard
|
|
94
|
+
// the author's styling), so a Previous/Next list can still carry them.
|
|
95
|
+
const code = await runPlugin({}, NUMBERED_CONTROLS)
|
|
96
|
+
|
|
97
|
+
expect(code).not.toContain('ds_0_pageTokens')
|
|
98
|
+
expect(code).not.toContain('thq-first-elm')
|
|
99
|
+
expect(code).not.toContain('thq-last-elm')
|
|
100
|
+
expect(code).not.toContain('thq-pages-elm')
|
|
101
|
+
// Previous/Next are still wired exactly as before.
|
|
102
|
+
expect(code).toContain('thq-previous-elm')
|
|
103
|
+
expect(code).toContain('disabled={ds_0_page <= 1}')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('still finds Previous/Next by class name in projects predating the marker', async () => {
|
|
107
|
+
const code = await runPlugin({}, ['previous', 'next'], /* legacyOnly */ true)
|
|
108
|
+
|
|
109
|
+
expect(code).toContain('products-list-thq-previous-elm')
|
|
110
|
+
expect(code).toContain('disabled={ds_0_page <= 1}')
|
|
111
|
+
expect(code).toContain('disabled={ds_0_page >= ds_0_maxPages}')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as types from '@babel/types'
|
|
2
|
+
import generator from '@babel/generator'
|
|
3
|
+
import {
|
|
4
|
+
ChunkType,
|
|
5
|
+
FileType,
|
|
6
|
+
ComponentStructure,
|
|
7
|
+
ChunkDefinition,
|
|
8
|
+
} from '@teleporthq/teleport-types'
|
|
9
|
+
import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
|
|
10
|
+
|
|
11
|
+
// Same minimal `const TestComponent = (props) => { return <div/> }` shell the
|
|
12
|
+
// search-url-sync suite uses: the reset effect is derived from the UIDL
|
|
13
|
+
// registry, not from the JSX, so an empty body is enough to observe it.
|
|
14
|
+
const makeComponentChunk = (): ChunkDefinition => {
|
|
15
|
+
const body = types.blockStatement([
|
|
16
|
+
types.returnStatement(
|
|
17
|
+
types.jsxElement(
|
|
18
|
+
types.jsxOpeningElement(types.jsxIdentifier('div'), [], true),
|
|
19
|
+
null,
|
|
20
|
+
[],
|
|
21
|
+
true
|
|
22
|
+
)
|
|
23
|
+
),
|
|
24
|
+
])
|
|
25
|
+
const arrow = types.arrowFunctionExpression([types.identifier('props')], body)
|
|
26
|
+
const declaration = types.variableDeclaration('const', [
|
|
27
|
+
types.variableDeclarator(types.identifier('TestComponent'), arrow),
|
|
28
|
+
])
|
|
29
|
+
return {
|
|
30
|
+
name: 'jsx-component',
|
|
31
|
+
type: ChunkType.AST,
|
|
32
|
+
fileType: FileType.JS,
|
|
33
|
+
linkAfter: [],
|
|
34
|
+
content: declaration,
|
|
35
|
+
meta: {},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ListOptions {
|
|
40
|
+
searchEnabled?: boolean
|
|
41
|
+
// A filter destination bound to component state (the category dropdown).
|
|
42
|
+
stateFilter?: string
|
|
43
|
+
// A filter destination read straight from the URL (`?categoryFilter=`).
|
|
44
|
+
urlFilter?: string
|
|
45
|
+
// A dynamic sort expression reading component state (the sort dropdown).
|
|
46
|
+
sortExpression?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// tslint:disable-next-line:no-any
|
|
50
|
+
const makeUidlNode = (options: ListOptions): any => {
|
|
51
|
+
// tslint:disable-next-line:no-any
|
|
52
|
+
const filters: any[] = []
|
|
53
|
+
if (options.stateFilter) {
|
|
54
|
+
filters.push({
|
|
55
|
+
source: 'category_filter_ids',
|
|
56
|
+
operand: 'array_overlap',
|
|
57
|
+
destination: {
|
|
58
|
+
type: 'dynamic',
|
|
59
|
+
content: { referenceType: 'state', id: options.stateFilter },
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
if (options.urlFilter) {
|
|
64
|
+
filters.push({
|
|
65
|
+
source: 'category_filter_ids',
|
|
66
|
+
operand: 'array_overlap',
|
|
67
|
+
destination: {
|
|
68
|
+
type: 'dynamic',
|
|
69
|
+
content: { referenceType: 'urlSearchParams', id: options.urlFilter },
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
type: 'data-source-list',
|
|
76
|
+
content: {
|
|
77
|
+
renderPropIdentifier: 'items',
|
|
78
|
+
resourceDefinition: {
|
|
79
|
+
dataSourceId: 'ds1',
|
|
80
|
+
tableName: 'products',
|
|
81
|
+
dataSourceType: 'postgresql',
|
|
82
|
+
},
|
|
83
|
+
resource: {
|
|
84
|
+
params: {
|
|
85
|
+
queryColumns: { content: ['name'] },
|
|
86
|
+
...(filters.length > 0 ? { filters: { content: filters } } : {}),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
nodes: {
|
|
90
|
+
success: {
|
|
91
|
+
type: 'cms-list-repeater',
|
|
92
|
+
content: {
|
|
93
|
+
renderPropIdentifier: 'product',
|
|
94
|
+
paginated: true,
|
|
95
|
+
perPage: 20,
|
|
96
|
+
searchEnabled: options.searchEnabled !== false,
|
|
97
|
+
searchDebounce: 300,
|
|
98
|
+
...(options.sortExpression
|
|
99
|
+
? {
|
|
100
|
+
sort: { type: 'expr', content: options.sortExpression },
|
|
101
|
+
sortDirection: { type: 'static', content: 'asc' },
|
|
102
|
+
}
|
|
103
|
+
: {}),
|
|
104
|
+
nodes: { list: { type: 'element', content: { elementType: 'div' } } },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Babel prints object literals across several lines; assertions about a whole
|
|
113
|
+
// statement read better against a whitespace-collapsed copy.
|
|
114
|
+
const collapse = (code: string): string => code.replace(/\s+/g, ' ')
|
|
115
|
+
|
|
116
|
+
const runPlugin = async (options: ListOptions): Promise<string> => {
|
|
117
|
+
const chunk = makeComponentChunk()
|
|
118
|
+
const structure: ComponentStructure = {
|
|
119
|
+
uidl: { name: 'TestComponent', node: makeUidlNode(options) },
|
|
120
|
+
chunks: [chunk],
|
|
121
|
+
dependencies: {},
|
|
122
|
+
options: { dataSources: {}, extractedResources: {} },
|
|
123
|
+
} as never
|
|
124
|
+
await createNextArrayMapperPaginationPlugin()(structure)
|
|
125
|
+
return generator(chunk.content as types.Node).code
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe('pagination plugin — page resets to 1 when a filter or sort changes', () => {
|
|
129
|
+
it('resets the combined state page for a state-bound filter', async () => {
|
|
130
|
+
const code = await runPlugin({ stateFilter: 'selectedCategory' })
|
|
131
|
+
|
|
132
|
+
expect(code).toContain('const ds_0_filterResetRef = useRef(null)')
|
|
133
|
+
expect(code).toContain('const __sig = JSON.stringify([selectedCategory])')
|
|
134
|
+
expect(code).toContain('if (ds_0_filterResetRef.current === __sig) return')
|
|
135
|
+
// First OBSERVED value is recorded, never acted on.
|
|
136
|
+
expect(code).toContain('const __first = ds_0_filterResetRef.current === null')
|
|
137
|
+
expect(code).toContain('if (__first) return')
|
|
138
|
+
// Functional update that bails when already on page 1, so a visitor who
|
|
139
|
+
// never paginated does not get an extra render (and refetch).
|
|
140
|
+
expect(collapse(code)).toContain(
|
|
141
|
+
'setDs_0_state(state => state.page === 1 ? state : { ...state, page: 1 })'
|
|
142
|
+
)
|
|
143
|
+
expect(code).toContain('}, [selectedCategory])')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('tracks the sort state alongside the filter', async () => {
|
|
147
|
+
const code = await runPlugin({
|
|
148
|
+
stateFilter: 'selectedCategory',
|
|
149
|
+
sortExpression: 'sortBy.split("-")[0]',
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expect(code).toContain('const __sig = JSON.stringify([selectedCategory, sortBy])')
|
|
153
|
+
expect(code).toContain('}, [selectedCategory, sortBy])')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('guards on router.isReady and null-coalesces URL params so deep links survive', async () => {
|
|
157
|
+
const code = await runPlugin({ urlFilter: 'categoryFilter' })
|
|
158
|
+
|
|
159
|
+
// ⛔ The reason this effect hashes a signature instead of skipping the first
|
|
160
|
+
// run: router.query is empty until the router hydrates, and treating that
|
|
161
|
+
// hydration as a change would reset a `?page=3&categoryFilter=X` deep link.
|
|
162
|
+
expect(code).toContain('if (!router.isReady) return')
|
|
163
|
+
expect(code).toContain('const __sig = JSON.stringify([router.query.categoryFilter ?? null])')
|
|
164
|
+
expect(code).toContain('}, [router.query.categoryFilter, router.isReady])')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('uses the standalone page setter for a paginated-only list', async () => {
|
|
168
|
+
const code = await runPlugin({ searchEnabled: false, stateFilter: 'selectedCategory' })
|
|
169
|
+
|
|
170
|
+
expect(code).toContain('setDs_0_page(page => page === 1 ? page : 1)')
|
|
171
|
+
expect(code).not.toContain('setDs_0_state(')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('emits nothing for a list that has no filter and no dynamic sort', async () => {
|
|
175
|
+
const code = await runPlugin({})
|
|
176
|
+
|
|
177
|
+
// Regression guard: an unfiltered mapper must generate exactly what it did
|
|
178
|
+
// before this feature existed.
|
|
179
|
+
expect(code).not.toContain('filterResetRef')
|
|
180
|
+
expect(code).not.toContain('__sig')
|
|
181
|
+
})
|
|
182
|
+
})
|