@teleporthq/teleport-plugin-next-data-source 0.43.62 → 0.43.64
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__/blog-post-categories.test.ts +131 -0
- package/__tests__/custom-page-transform.test.ts +119 -0
- package/__tests__/ecommerce-product-ratings.test.ts +155 -0
- package/__tests__/ecommerce-product-storefront-contract.test.ts +147 -0
- package/__tests__/filter-groups.test.ts +174 -0
- package/__tests__/product-reviews-structured-data.test.ts +196 -0
- package/dist/cjs/data-source-fetchers.d.ts +3 -3
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/airtable.d.ts.map +1 -1
- package/dist/cjs/fetchers/airtable.js +1 -1
- package/dist/cjs/fetchers/airtable.js.map +1 -1
- package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
- package/dist/cjs/fetchers/clickhouse.js +1 -1
- package/dist/cjs/fetchers/clickhouse.js.map +1 -1
- package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
- package/dist/cjs/fetchers/csv-file.js +3 -3
- package/dist/cjs/fetchers/csv-file.js.map +1 -1
- package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
- package/dist/cjs/fetchers/firestore.js +1 -1
- package/dist/cjs/fetchers/firestore.js.map +1 -1
- package/dist/cjs/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/cjs/fetchers/google-sheets.js +1 -1
- package/dist/cjs/fetchers/google-sheets.js.map +1 -1
- package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
- package/dist/cjs/fetchers/javascript.js +1 -1
- package/dist/cjs/fetchers/javascript.js.map +1 -1
- package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
- package/dist/cjs/fetchers/mariadb.js +1 -1
- package/dist/cjs/fetchers/mariadb.js.map +1 -1
- package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
- package/dist/cjs/fetchers/mongodb.js +1 -1
- package/dist/cjs/fetchers/mongodb.js.map +1 -1
- package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
- package/dist/cjs/fetchers/mysql.js +1 -1
- package/dist/cjs/fetchers/mysql.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +1 -1
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/redis.d.ts.map +1 -1
- package/dist/cjs/fetchers/redis.js +1 -1
- package/dist/cjs/fetchers/redis.js.map +1 -1
- package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
- package/dist/cjs/fetchers/redshift.js +2 -2
- package/dist/cjs/fetchers/redshift.js.map +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +2 -2
- package/dist/cjs/fetchers/rest-api.js.map +1 -1
- package/dist/cjs/fetchers/supabase.d.ts.map +1 -1
- package/dist/cjs/fetchers/supabase.js +1 -1
- package/dist/cjs/fetchers/supabase.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts +2 -2
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +1 -1
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/turso.d.ts.map +1 -1
- package/dist/cjs/fetchers/turso.js +1 -1
- package/dist/cjs/fetchers/turso.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/transformations/blog-post.d.ts +7 -1
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +4 -2
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/category-taxonomy.d.ts +33 -0
- package/dist/cjs/transformations/category-taxonomy.d.ts.map +1 -0
- package/dist/cjs/transformations/category-taxonomy.js +42 -0
- package/dist/cjs/transformations/category-taxonomy.js.map +1 -0
- package/dist/cjs/transformations/custom-page.d.ts +18 -0
- package/dist/cjs/transformations/custom-page.d.ts.map +1 -0
- package/dist/cjs/transformations/custom-page.js +24 -0
- package/dist/cjs/transformations/custom-page.js.map +1 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +2 -35
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +23 -4
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +38 -8
- 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/cjs/utils.d.ts +35 -3
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +116 -18
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/data-source-fetchers.d.ts +3 -3
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/airtable.d.ts.map +1 -1
- package/dist/esm/fetchers/airtable.js +2 -2
- package/dist/esm/fetchers/airtable.js.map +1 -1
- package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
- package/dist/esm/fetchers/clickhouse.js +2 -2
- package/dist/esm/fetchers/clickhouse.js.map +1 -1
- package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
- package/dist/esm/fetchers/csv-file.js +4 -4
- package/dist/esm/fetchers/csv-file.js.map +1 -1
- package/dist/esm/fetchers/firestore.d.ts.map +1 -1
- package/dist/esm/fetchers/firestore.js +2 -2
- package/dist/esm/fetchers/firestore.js.map +1 -1
- package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/esm/fetchers/google-sheets.js +2 -2
- package/dist/esm/fetchers/google-sheets.js.map +1 -1
- package/dist/esm/fetchers/javascript.d.ts.map +1 -1
- package/dist/esm/fetchers/javascript.js +2 -2
- package/dist/esm/fetchers/javascript.js.map +1 -1
- package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
- package/dist/esm/fetchers/mariadb.js +2 -2
- package/dist/esm/fetchers/mariadb.js.map +1 -1
- package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
- package/dist/esm/fetchers/mongodb.js +2 -2
- package/dist/esm/fetchers/mongodb.js.map +1 -1
- package/dist/esm/fetchers/mysql.d.ts.map +1 -1
- package/dist/esm/fetchers/mysql.js +2 -2
- package/dist/esm/fetchers/mysql.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +2 -2
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/redis.d.ts.map +1 -1
- package/dist/esm/fetchers/redis.js +2 -2
- package/dist/esm/fetchers/redis.js.map +1 -1
- package/dist/esm/fetchers/redshift.d.ts.map +1 -1
- package/dist/esm/fetchers/redshift.js +3 -3
- package/dist/esm/fetchers/redshift.js.map +1 -1
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +3 -3
- package/dist/esm/fetchers/rest-api.js.map +1 -1
- package/dist/esm/fetchers/supabase.d.ts.map +1 -1
- package/dist/esm/fetchers/supabase.js +2 -2
- package/dist/esm/fetchers/supabase.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts +2 -2
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +2 -2
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/turso.d.ts.map +1 -1
- package/dist/esm/fetchers/turso.js +2 -2
- package/dist/esm/fetchers/turso.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +1 -1
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/transformations/blog-post.d.ts +7 -1
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +4 -2
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/category-taxonomy.d.ts +33 -0
- package/dist/esm/transformations/category-taxonomy.d.ts.map +1 -0
- package/dist/esm/transformations/category-taxonomy.js +37 -0
- package/dist/esm/transformations/category-taxonomy.js.map +1 -0
- package/dist/esm/transformations/custom-page.d.ts +18 -0
- package/dist/esm/transformations/custom-page.d.ts.map +1 -0
- package/dist/esm/transformations/custom-page.js +20 -0
- package/dist/esm/transformations/custom-page.js.map +1 -0
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +3 -36
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +23 -4
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +37 -7
- 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/dist/esm/utils.d.ts +35 -3
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js +112 -17
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/data-source-fetchers.ts +3 -3
- package/src/fetchers/airtable.ts +35 -36
- package/src/fetchers/clickhouse.ts +26 -36
- package/src/fetchers/csv-file.ts +13 -21
- package/src/fetchers/firestore.ts +41 -35
- package/src/fetchers/google-sheets.ts +15 -23
- package/src/fetchers/javascript.ts +11 -21
- package/src/fetchers/mariadb.ts +33 -44
- package/src/fetchers/mongodb.ts +66 -81
- package/src/fetchers/mysql.ts +33 -45
- package/src/fetchers/postgresql.ts +52 -62
- package/src/fetchers/redis.ts +6 -5
- package/src/fetchers/redshift.ts +29 -41
- package/src/fetchers/rest-api.ts +11 -21
- package/src/fetchers/supabase.ts +104 -41
- package/src/fetchers/teleport.ts +55 -66
- package/src/fetchers/turso.ts +27 -39
- package/src/index.ts +1 -1
- package/src/pagination-plugin.ts +3 -6
- package/src/transformations/blog-post.ts +28 -9
- package/src/transformations/category-taxonomy.ts +107 -0
- package/src/transformations/custom-page.ts +83 -0
- package/src/transformations/ecommerce-product.ts +177 -78
- package/src/transformations/index.ts +85 -11
- package/src/transformations/shared-utils.ts +9 -0
- package/src/utils.ts +191 -34
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { generateBlogPostTransformationCode } from '../src/transformations/blog-post'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
import { buildProductTransformOptions, getTransformationCode } from '../src/transformations'
|
|
4
|
+
import type { UIDLBlogSettings, UIDLEcommerceCategory } from '@teleporthq/teleport-types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A blog post's assigned categories are resolved AT REQUEST TIME against a
|
|
8
|
+
* taxonomy baked into the generated fetcher — there is no DB table for it, it
|
|
9
|
+
* lives only in `blogSettings.categories`. The post-details BREADCRUMBS map over
|
|
10
|
+
* the resulting `categories` array and gate each crumb on it, so a post whose
|
|
11
|
+
* ids resolve to nothing renders no crumbs at all.
|
|
12
|
+
*
|
|
13
|
+
* The editor produces the same array through `resolveAssignedCategories`
|
|
14
|
+
* (teleport-gui `features/shared/taxonomy/category-tree.ts`); a difference
|
|
15
|
+
* between the two is a canvas that shows crumbs the deployed blog does not.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const category = (
|
|
19
|
+
id: string,
|
|
20
|
+
name: string,
|
|
21
|
+
slug: string,
|
|
22
|
+
children: UIDLEcommerceCategory[] = [],
|
|
23
|
+
translations?: UIDLEcommerceCategory['translations']
|
|
24
|
+
): UIDLEcommerceCategory => ({
|
|
25
|
+
id,
|
|
26
|
+
name,
|
|
27
|
+
slug,
|
|
28
|
+
parentId: null,
|
|
29
|
+
order: 0,
|
|
30
|
+
children,
|
|
31
|
+
...(translations ? { translations } : {}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const TAXONOMY: UIDLEcommerceCategory[] = [
|
|
35
|
+
category('news', 'News', 'news', [
|
|
36
|
+
{ ...category('releases', 'Releases', 'releases'), parentId: 'news' },
|
|
37
|
+
]),
|
|
38
|
+
category('guides', 'Guides', 'guides', [], { fr: { name: 'Guides FR' } }),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const buildPost = (
|
|
42
|
+
categories: UIDLEcommerceCategory[] | undefined
|
|
43
|
+
): ((record: unknown, options?: unknown) => Record<string, unknown>) => {
|
|
44
|
+
const code =
|
|
45
|
+
generateSharedTransformationCode() + '\n' + generateBlogPostTransformationCode({ categories })
|
|
46
|
+
return new Function(code + '\nreturn buildBlogPost;')() as (
|
|
47
|
+
record: unknown,
|
|
48
|
+
options?: unknown
|
|
49
|
+
) => Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const POST = { id: 'p1', title: 'T', slug: 't', status: 'published' }
|
|
53
|
+
|
|
54
|
+
describe('blog post transform — assigned categories', () => {
|
|
55
|
+
it('resolves category_ids to {id,name,slug}, in the order they were assigned', () => {
|
|
56
|
+
const post = buildPost(TAXONOMY)({
|
|
57
|
+
...POST,
|
|
58
|
+
category_ids: JSON.stringify(['releases', 'guides']),
|
|
59
|
+
})
|
|
60
|
+
expect(post.categories).toEqual([
|
|
61
|
+
{ id: 'releases', name: 'Releases', slug: 'releases' },
|
|
62
|
+
{ id: 'guides', name: 'Guides', slug: 'guides' },
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('drops ids that resolve to nothing — a deleted category leaves no ghost crumb', () => {
|
|
67
|
+
const post = buildPost(TAXONOMY)({
|
|
68
|
+
...POST,
|
|
69
|
+
category_ids: JSON.stringify(['gone', 'news']),
|
|
70
|
+
})
|
|
71
|
+
expect(post.categories).toEqual([{ id: 'news', name: 'News', slug: 'news' }])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('is empty (never undefined) for a post that names no category at all', () => {
|
|
75
|
+
expect(buildPost(TAXONOMY)({ ...POST, category_ids: null }).categories).toEqual([])
|
|
76
|
+
expect(buildPost(TAXONOMY)(POST).categories).toEqual([])
|
|
77
|
+
expect(
|
|
78
|
+
buildPost(undefined)({ ...POST, category_ids: JSON.stringify(['news']) }).categories
|
|
79
|
+
).toEqual([])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('falls back to the row’s own NAME when it has no resolvable ids', () => {
|
|
83
|
+
// ⛔ A row written before `category_ids` existed still carries `category`.
|
|
84
|
+
// Templates iterate `categories` now, so without this the card and the post
|
|
85
|
+
// header go blank exactly where a bound text used to be. The id is EMPTY, so
|
|
86
|
+
// a filter link built from it carries no value — which every listing reads
|
|
87
|
+
// as "no filter" — rather than pointing at a category that does not exist.
|
|
88
|
+
const legacy = buildPost(TAXONOMY)({ ...POST, category_ids: null, category: 'Old News' })
|
|
89
|
+
expect(legacy.categories).toEqual([{ id: '', name: 'Old News', slug: 'old-news' }])
|
|
90
|
+
|
|
91
|
+
// A row with real ids never reaches the fallback.
|
|
92
|
+
const modern = buildPost(TAXONOMY)({
|
|
93
|
+
...POST,
|
|
94
|
+
category: 'Old News',
|
|
95
|
+
category_ids: JSON.stringify(['news']),
|
|
96
|
+
})
|
|
97
|
+
expect(modern.categories).toEqual([{ id: 'news', name: 'News', slug: 'news' }])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('localizes the NAME per request and leaves id/slug language-neutral', () => {
|
|
101
|
+
const post = buildPost(TAXONOMY)(
|
|
102
|
+
{ ...POST, category_ids: JSON.stringify(['guides']) },
|
|
103
|
+
{ currentLanguage: 'fr', mainLanguage: 'en' }
|
|
104
|
+
)
|
|
105
|
+
expect(post.categories).toEqual([{ id: 'guides', name: 'Guides FR', slug: 'guides' }])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('keeps `category` as the denormalized primary NAME beside the resolved array', () => {
|
|
109
|
+
const post = buildPost(TAXONOMY)({
|
|
110
|
+
...POST,
|
|
111
|
+
category: 'News',
|
|
112
|
+
category_ids: JSON.stringify(['news']),
|
|
113
|
+
})
|
|
114
|
+
expect(post.category).toBe('News')
|
|
115
|
+
expect(post.categories).toHaveLength(1)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('blog taxonomy plumbing', () => {
|
|
120
|
+
it('routes blogSettings.categories into the blog-post fetcher, not the product one', () => {
|
|
121
|
+
const options = buildProductTransformOptions({
|
|
122
|
+
blogSettings: { categories: TAXONOMY } as UIDLBlogSettings,
|
|
123
|
+
})
|
|
124
|
+
expect(options.blogCategories).toBe(TAXONOMY)
|
|
125
|
+
expect(options.categories).toBeUndefined()
|
|
126
|
+
|
|
127
|
+
const code = getTransformationCode('teleport_blog_posts', options)
|
|
128
|
+
expect(code).toContain('var BLOG_CATEGORIES_BY_ID = ')
|
|
129
|
+
expect(code).toContain('"releases"')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { generateCustomPageTransformationCode } from '../src/transformations/custom-page'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
import {
|
|
4
|
+
detectTransformationType,
|
|
5
|
+
getTransformExpression,
|
|
6
|
+
getTransformWrapperCode,
|
|
7
|
+
} from '../src/transformations'
|
|
8
|
+
|
|
9
|
+
// The `teleport_pages` transform is what lets a custom page's <Head> bind its
|
|
10
|
+
// SEO with plain `??` fallbacks, and what lets getStaticProps answer the row's
|
|
11
|
+
// own redirect — every field it emits is read by generated code somewhere. So
|
|
12
|
+
// these evaluate the EMITTED code, the same way the runtime does.
|
|
13
|
+
|
|
14
|
+
type Build = (record: unknown, options?: Record<string, unknown>) => Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
const evalTransform = (): Build => {
|
|
17
|
+
const bundle = generateSharedTransformationCode() + '\n' + generateCustomPageTransformationCode()
|
|
18
|
+
return new Function(bundle + '\nreturn buildCustomPage;')() as Build
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const buildCustomPage = evalTransform()
|
|
22
|
+
|
|
23
|
+
const page = (overrides: Record<string, unknown> = {}) => ({
|
|
24
|
+
id: 'p1',
|
|
25
|
+
title: 'About us',
|
|
26
|
+
slug: 'about-us',
|
|
27
|
+
status: 'published',
|
|
28
|
+
blocks: '[{"id":"b1","component":"tq:heading","props":{"text":"Hi"}}]',
|
|
29
|
+
...overrides,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('custom page transform — the row itself', () => {
|
|
33
|
+
it('keeps every stored column, the blocks column above all', () => {
|
|
34
|
+
const built = buildCustomPage(page({ extra_column: 'kept' }))
|
|
35
|
+
expect(built.blocks).toBe('[{"id":"b1","component":"tq:heading","props":{"text":"Hi"}}]')
|
|
36
|
+
expect(built.slug).toBe('about-us')
|
|
37
|
+
expect(built.extra_column).toBe('kept')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('is routed for the platform pages table only', () => {
|
|
41
|
+
expect(detectTransformationType('teleport_pages')).toBe('custom-page')
|
|
42
|
+
expect(detectTransformationType('public.TELEPORT_PAGES')).toBe('custom-page')
|
|
43
|
+
expect(detectTransformationType('pages')).toBeNull()
|
|
44
|
+
expect(detectTransformationType('my_pages')).toBeNull()
|
|
45
|
+
expect(getTransformExpression('teleport_pages')).toContain('transformRecords')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('wraps the transform without the related-items or variant enrichment', () => {
|
|
49
|
+
const wrapper = getTransformWrapperCode('teleport_pages')
|
|
50
|
+
expect(wrapper).toContain('transformCustomPages(records, options)')
|
|
51
|
+
expect(wrapper).not.toContain('relatedPostsById')
|
|
52
|
+
expect(wrapper).not.toContain('relatedProductsById')
|
|
53
|
+
expect(wrapper).not.toContain('variantsByProductId')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('custom page transform — the meta pair', () => {
|
|
58
|
+
it('falls the meta title back to the page title, and trims what was typed', () => {
|
|
59
|
+
expect(buildCustomPage(page()).metaTitle).toBe('About us')
|
|
60
|
+
expect(buildCustomPage(page({ meta_title: ' Our story ' })).metaTitle).toBe('Our story')
|
|
61
|
+
expect(buildCustomPage(page({ meta_title: ' ' })).metaTitle).toBe('About us')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('emits null for an unset description so the page-level value inherits', () => {
|
|
65
|
+
expect(buildCustomPage(page()).metaDescription).toBeNull()
|
|
66
|
+
expect(buildCustomPage(page({ meta_description: ' A page. ' })).metaDescription).toBe('A page.')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('custom page transform — robots, canonical, redirect', () => {
|
|
71
|
+
it('emits robotsContent as "noindex" or NULL, never "" or false', () => {
|
|
72
|
+
expect(buildCustomPage(page()).robotsContent).toBeNull()
|
|
73
|
+
expect(buildCustomPage(page({ no_index: false })).robotsContent).toBeNull()
|
|
74
|
+
expect(buildCustomPage(page({ no_index: true })).robotsContent).toBe('noindex')
|
|
75
|
+
expect(buildCustomPage(page({ no_index: 'true' })).robotsContent).toBe('noindex')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('normalises the canonical URL', () => {
|
|
79
|
+
expect(buildCustomPage(page()).canonicalUrl).toBeNull()
|
|
80
|
+
expect(buildCustomPage(page({ canonical_url: ' https://x.io/a ' })).canonicalUrl).toBe(
|
|
81
|
+
'https://x.io/a'
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps a redirect type only beside a real destination, and only 301/302', () => {
|
|
86
|
+
expect(buildCustomPage(page({ redirect_type: '301' })).redirectType).toBeNull()
|
|
87
|
+
expect(buildCustomPage(page({ redirect_url: '/new', redirect_type: '302' }))).toMatchObject({
|
|
88
|
+
redirectUrl: '/new',
|
|
89
|
+
redirectType: '302',
|
|
90
|
+
})
|
|
91
|
+
expect(
|
|
92
|
+
buildCustomPage(page({ redirect_url: '/new', redirect_type: '307' })).redirectType
|
|
93
|
+
).toBeNull()
|
|
94
|
+
expect(buildCustomPage(page({ redirect_url: ' ' })).redirectUrl).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('custom page transform — Open Graph', () => {
|
|
99
|
+
it('falls og title/description back to the meta pair', () => {
|
|
100
|
+
const built = buildCustomPage(page({ meta_description: 'Meta' }))
|
|
101
|
+
expect(built.ogTitle).toBe('About us')
|
|
102
|
+
expect(built.ogDescription).toBe('Meta')
|
|
103
|
+
|
|
104
|
+
const own = buildCustomPage(page({ og_title: 'Share me', og_description: 'Shared' }))
|
|
105
|
+
expect(own.ogTitle).toBe('Share me')
|
|
106
|
+
expect(own.ogDescription).toBe('Shared')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('resolves an asset id to its URL and passes a plain URL through', () => {
|
|
110
|
+
const assetMap = { TQ_asset1: { remoteSrc: 'https://cdn.example/a.png' } }
|
|
111
|
+
expect(buildCustomPage(page({ og_image: 'TQ_asset1' }), { assetMap }).ogImage).toBe(
|
|
112
|
+
'https://cdn.example/a.png'
|
|
113
|
+
)
|
|
114
|
+
expect(buildCustomPage(page({ og_image: 'https://x.io/b.png' }), { assetMap }).ogImage).toBe(
|
|
115
|
+
'https://x.io/b.png'
|
|
116
|
+
)
|
|
117
|
+
expect(buildCustomPage(page()).ogImage).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The aggregate star rating a product card and the product page draw.
|
|
6
|
+
*
|
|
7
|
+
* The one rule that matters more than the arithmetic: a product nobody has
|
|
8
|
+
* reviewed, and a product whose rating lookup FAILED, must render identically —
|
|
9
|
+
* as no stars at all. "0.0 out of 5" on an unreviewed product is the single
|
|
10
|
+
* worst thing a storefront can say about it by accident, and it is exactly what
|
|
11
|
+
* a well-meaning `|| 0` produces.
|
|
12
|
+
*
|
|
13
|
+
* PARITY: the same fixture table runs against the editor/canvas copy in
|
|
14
|
+
* teleport-gui's `features/e-commerce/utils/__tests__/product-rating-parity.spec.ts`.
|
|
15
|
+
* Keep the two tables identical — that is what proves the canvas and the
|
|
16
|
+
* deployed store agree.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const buildProduct = (): ((record: unknown, options?: unknown) => Record<string, unknown>) => {
|
|
20
|
+
const code =
|
|
21
|
+
generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode({})
|
|
22
|
+
return new Function(code + '\nreturn buildEcommerceProduct;')() as (
|
|
23
|
+
record: unknown,
|
|
24
|
+
options?: unknown
|
|
25
|
+
) => Record<string, unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PRODUCT = { id: 'p1', name: 'P', slug: 'p', price: '10.00', currency: 'USD' }
|
|
29
|
+
|
|
30
|
+
const withRating = (average: number | null, count: number) =>
|
|
31
|
+
buildProduct()(PRODUCT, { ratingsByProductId: { p1: { average, count } } })
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* SHARED PARITY TABLE — keep byte-identical with the GUI spec.
|
|
35
|
+
* [average, count, expected label, expected star states]
|
|
36
|
+
*/
|
|
37
|
+
export const RATING_FIXTURES: Array<{
|
|
38
|
+
average: number
|
|
39
|
+
count: number
|
|
40
|
+
label: string
|
|
41
|
+
stars: string[]
|
|
42
|
+
}> = [
|
|
43
|
+
{ average: 5, count: 3, label: '5.0', stars: ['full', 'full', 'full', 'full', 'full'] },
|
|
44
|
+
{ average: 4.5, count: 128, label: '4.5', stars: ['full', 'full', 'full', 'full', 'half'] },
|
|
45
|
+
{ average: 4.26, count: 9, label: '4.3', stars: ['full', 'full', 'full', 'full', 'half'] },
|
|
46
|
+
{ average: 4.24, count: 9, label: '4.2', stars: ['full', 'full', 'full', 'full', 'empty'] },
|
|
47
|
+
{ average: 3.75, count: 4, label: '3.8', stars: ['full', 'full', 'full', 'full', 'empty'] },
|
|
48
|
+
{ average: 3.5, count: 4, label: '3.5', stars: ['full', 'full', 'full', 'half', 'empty'] },
|
|
49
|
+
{ average: 2.7, count: 11, label: '2.7', stars: ['full', 'full', 'half', 'empty', 'empty'] },
|
|
50
|
+
{ average: 1, count: 1, label: '1.0', stars: ['full', 'empty', 'empty', 'empty', 'empty'] },
|
|
51
|
+
{ average: 0.4, count: 2, label: '0.4', stars: ['half', 'empty', 'empty', 'empty', 'empty'] },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const starsOf = (product: Record<string, unknown>): string[] => [
|
|
55
|
+
product.ratingStar1 as string,
|
|
56
|
+
product.ratingStar2 as string,
|
|
57
|
+
product.ratingStar3 as string,
|
|
58
|
+
product.ratingStar4 as string,
|
|
59
|
+
product.ratingStar5 as string,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
describe('ecommerce product transform — aggregate rating', () => {
|
|
63
|
+
it.each(RATING_FIXTURES)(
|
|
64
|
+
'renders $average over $count reviews as "$label"',
|
|
65
|
+
({ average, count, label, stars }) => {
|
|
66
|
+
const product = withRating(average, count)
|
|
67
|
+
expect(product.ratingAverageLabel).toBe(label)
|
|
68
|
+
expect(product.ratingCountLabel).toBe(String(count))
|
|
69
|
+
expect(product.hasRatings).toBe('true')
|
|
70
|
+
expect(starsOf(product)).toEqual(stars)
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
it('shows NOTHING for a product with no reviews — not zero stars', () => {
|
|
75
|
+
const product = buildProduct()(PRODUCT, { ratingsByProductId: {} })
|
|
76
|
+
expect(product.hasRatings).toBe('false')
|
|
77
|
+
expect(product.ratingAverageLabel).toBe('')
|
|
78
|
+
expect(product.ratingCountLabel).toBe('')
|
|
79
|
+
expect(product.ratingAverage).toBe(0)
|
|
80
|
+
expect(product.ratingCount).toBe(0)
|
|
81
|
+
expect(starsOf(product)).toEqual(['empty', 'empty', 'empty', 'empty', 'empty'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// A failed lookup leaves the map empty, which is deliberately the SAME answer
|
|
85
|
+
// as "no reviews" — there is exactly one safe rendering for both.
|
|
86
|
+
it('shows nothing when the ratings map is absent entirely', () => {
|
|
87
|
+
const product = buildProduct()(PRODUCT, {})
|
|
88
|
+
expect(product.hasRatings).toBe('false')
|
|
89
|
+
expect(starsOf(product)).toEqual(['empty', 'empty', 'empty', 'empty', 'empty'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('shows nothing when a count arrives with no usable average', () => {
|
|
93
|
+
// Every approved row had a NULL rating: AVG() is NULL, COUNT() is not.
|
|
94
|
+
expect(withRating(null, 4).hasRatings).toBe('false')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('shows nothing for a zero count even if an average somehow came back', () => {
|
|
98
|
+
expect(withRating(4.5, 0).hasRatings).toBe('false')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// `rating` is a plain integer column: the admin panel, a CSV import or a
|
|
102
|
+
// hand-written UPDATE can all put a 7 in it. An unclamped average would ask
|
|
103
|
+
// for a sixth star slot that does not exist.
|
|
104
|
+
it('clamps an out-of-range average to five stars', () => {
|
|
105
|
+
const product = withRating(7.4, 2)
|
|
106
|
+
expect(product.ratingAverage).toBe(5)
|
|
107
|
+
expect(product.ratingAverageLabel).toBe('5.0')
|
|
108
|
+
expect(starsOf(product)).toEqual(['full', 'full', 'full', 'full', 'full'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('treats a negative average as no rating at all', () => {
|
|
112
|
+
expect(withRating(-3, 2).hasRatings).toBe('false')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('emits the count as a whole number even when the driver returns a string', () => {
|
|
116
|
+
const product = buildProduct()(PRODUCT, {
|
|
117
|
+
ratingsByProductId: { p1: { average: '4.5', count: '12' } },
|
|
118
|
+
})
|
|
119
|
+
expect(product.ratingCount).toBe(12)
|
|
120
|
+
expect(product.ratingCountLabel).toBe('12')
|
|
121
|
+
expect(product.ratingAverageLabel).toBe('4.5')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// The details page draws its related products as real cards, each with its
|
|
125
|
+
// own star row. They are covered by the same batched query, so they must get
|
|
126
|
+
// the same enrichment rather than transforming as unrated.
|
|
127
|
+
it('passes the ratings map down to related products', () => {
|
|
128
|
+
const product = buildProduct()(
|
|
129
|
+
{ ...PRODUCT, related_product_ids: JSON.stringify(['p2']) },
|
|
130
|
+
{
|
|
131
|
+
ratingsByProductId: { p1: { average: 5, count: 1 }, p2: { average: 3, count: 8 } },
|
|
132
|
+
relatedProductsById: {
|
|
133
|
+
p2: { id: 'p2', name: 'Other', slug: 'other', price: '5.00', status: 'active' },
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
const related = product.relatedProducts as Array<Record<string, unknown>>
|
|
138
|
+
expect(related).toHaveLength(1)
|
|
139
|
+
expect(related[0].hasRatings).toBe('true')
|
|
140
|
+
expect(related[0].ratingAverageLabel).toBe('3.0')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('keeps the label and the stars consistent for every fixture', () => {
|
|
144
|
+
// The label is rounded to one decimal and the stars to the nearest half OF
|
|
145
|
+
// THAT LABEL, so the stars can never contradict the number beside them.
|
|
146
|
+
for (const fixture of RATING_FIXTURES) {
|
|
147
|
+
const product = withRating(fixture.average, fixture.count)
|
|
148
|
+
const filled = starsOf(product).reduce(
|
|
149
|
+
(total, star) => total + (star === 'full' ? 1 : star === 'half' ? 0.5 : 0),
|
|
150
|
+
0
|
|
151
|
+
)
|
|
152
|
+
expect(Math.abs(filled - Number(product.ratingAverageLabel))).toBeLessThanOrEqual(0.25)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { generateEcommerceProductTransformationCode } from '../src/transformations/ecommerce-product'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The storefront variant picker and the visible-properties pills are gated on
|
|
6
|
+
* fields this transform has to put on every product. When they were missing, the
|
|
7
|
+
* picker worked on the editor canvas (whose own transform emitted them) and was
|
|
8
|
+
* INVISIBLE in the deployed store — `requiresVariantSelection` came back
|
|
9
|
+
* undefined, so the picker root's `=== 'true'` gate never matched.
|
|
10
|
+
*
|
|
11
|
+
* That failure is invisible from the editor by construction: a screenshot
|
|
12
|
+
* showing the picker is the canvas, never the deployed store. This test is the
|
|
13
|
+
* thing that can see it, so it asserts the whole gate contract rather than any
|
|
14
|
+
* one field.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const evalBuildProduct = (): ((record: unknown, options?: unknown) => Record<string, unknown>) => {
|
|
18
|
+
const code =
|
|
19
|
+
generateSharedTransformationCode() + '\n' + generateEcommerceProductTransformationCode()
|
|
20
|
+
const fn = new Function(code + '\nreturn buildEcommerceProduct;')
|
|
21
|
+
return fn() as (record: unknown, options?: unknown) => Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const buildEcommerceProduct = evalBuildProduct()
|
|
25
|
+
|
|
26
|
+
const VARIANT_OPTIONS = [
|
|
27
|
+
{
|
|
28
|
+
key: 'size',
|
|
29
|
+
name: 'Size',
|
|
30
|
+
type: 'text',
|
|
31
|
+
values: [
|
|
32
|
+
{ value: 's', label: 'S' },
|
|
33
|
+
{ value: 'm', label: 'M' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
key: 'color',
|
|
38
|
+
name: 'Colour',
|
|
39
|
+
type: 'color',
|
|
40
|
+
values: [{ value: 'red', label: 'Red', color: '#ef4444' }],
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
// Combinations arrive from the batched lookup, keyed by product id — NOT from a
|
|
45
|
+
// column on the record.
|
|
46
|
+
const VARIANTS_BY_PRODUCT_ID = {
|
|
47
|
+
p1: [
|
|
48
|
+
{ id: 'v-s-red', options: { size: 's', color: 'red' }, price: 25, quantity: 4 },
|
|
49
|
+
{ id: 'v-m-red', options: { size: 'm', color: 'red' }, price: 27, quantity: 0 },
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const productWithVariants = () =>
|
|
54
|
+
buildEcommerceProduct(
|
|
55
|
+
{
|
|
56
|
+
id: 'p1',
|
|
57
|
+
name: 'Tee',
|
|
58
|
+
slug: 'tee',
|
|
59
|
+
price: '20',
|
|
60
|
+
currency: 'USD',
|
|
61
|
+
quantity: 4,
|
|
62
|
+
metadata: JSON.stringify({ Material: 'Cotton' }),
|
|
63
|
+
variant_options: JSON.stringify(VARIANT_OPTIONS),
|
|
64
|
+
},
|
|
65
|
+
{ variantsByProductId: VARIANTS_BY_PRODUCT_ID }
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const productWithoutVariants = () =>
|
|
69
|
+
buildEcommerceProduct(
|
|
70
|
+
{ id: 'p2', name: 'Mug', slug: 'mug', price: '10', currency: 'USD' },
|
|
71
|
+
{ variantsByProductId: {} }
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every field the generated storefront reads off a product to decide whether to
|
|
76
|
+
* render the picker, which values are selectable, and what price to show.
|
|
77
|
+
*/
|
|
78
|
+
const PICKER_CONTRACT = [
|
|
79
|
+
'variantOptions',
|
|
80
|
+
'requiresVariantSelection',
|
|
81
|
+
'variants',
|
|
82
|
+
'variantOptionsJson',
|
|
83
|
+
'variantsJson',
|
|
84
|
+
'defaultVariantId',
|
|
85
|
+
'defaultVariantPrice',
|
|
86
|
+
'hasPurchasableVariant',
|
|
87
|
+
'visibleProperties',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
describe('ecommerce product transform — storefront picker contract', () => {
|
|
91
|
+
it.each(PICKER_CONTRACT)('always emits %s, for a product WITH variants', (field) => {
|
|
92
|
+
expect(productWithVariants()).toHaveProperty(field)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// The picker is hidden by absence, so a product with no variants must still
|
|
96
|
+
// carry the keys — undefined and "false" are different bugs.
|
|
97
|
+
it.each(PICKER_CONTRACT)('always emits %s, for a product WITHOUT variants', (field) => {
|
|
98
|
+
expect(productWithoutVariants()).toHaveProperty(field)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('gates the picker with the STRING "true"/"false", never a boolean', () => {
|
|
102
|
+
expect(productWithVariants().requiresVariantSelection).toBe('true')
|
|
103
|
+
expect(productWithoutVariants().requiresVariantSelection).toBe('false')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('enriches every axis value with the per-value flags the picker reads', () => {
|
|
107
|
+
const options = productWithVariants().variantOptions as Array<{
|
|
108
|
+
key: string
|
|
109
|
+
name: string
|
|
110
|
+
values: Array<Record<string, unknown>>
|
|
111
|
+
}>
|
|
112
|
+
|
|
113
|
+
expect(options).toHaveLength(2)
|
|
114
|
+
for (const axis of options) {
|
|
115
|
+
expect(axis.name).toBeTruthy()
|
|
116
|
+
for (const value of axis.values) {
|
|
117
|
+
// Rendered label, colour-circle gate, and availability gate.
|
|
118
|
+
expect(value).toHaveProperty('label')
|
|
119
|
+
expect(value).toHaveProperty('showSwatch')
|
|
120
|
+
expect(value).toHaveProperty('isDead')
|
|
121
|
+
expect(['true', 'false']).toContain(value.showSwatch)
|
|
122
|
+
expect(['true', 'false']).toContain(value.isDead)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('ships the JSON companions the DOM-reading workflows parse', () => {
|
|
128
|
+
const product = productWithVariants()
|
|
129
|
+
|
|
130
|
+
// A data-* attribute bound to an array renders "[object Object]", so the
|
|
131
|
+
// picker workflows read these stringified companions instead.
|
|
132
|
+
expect(() => JSON.parse(product.variantOptionsJson as string)).not.toThrow()
|
|
133
|
+
expect(() => JSON.parse(product.variantsJson as string)).not.toThrow()
|
|
134
|
+
expect(JSON.parse(product.variantsJson as string)).toHaveLength(2)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('preselects an in-stock combination', () => {
|
|
138
|
+
const product = productWithVariants()
|
|
139
|
+
|
|
140
|
+
expect(product.defaultVariantId).toBe('v-s-red')
|
|
141
|
+
expect(product.hasPurchasableVariant).toBe('true')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('surfaces custom metadata as visible properties', () => {
|
|
145
|
+
expect(Array.isArray(productWithVariants().visibleProperties)).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
})
|