@teleporthq/teleport-project-generator-next 0.43.75 → 0.44.0

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.
Files changed (121) hide show
  1. package/__tests__/analytics-tracker-consent.test.ts +165 -0
  2. package/__tests__/booking-context-plugin.test.ts +96 -0
  3. package/__tests__/content-category-page-end2end.test.ts +3 -1
  4. package/__tests__/custom-pages-end2end.test.ts +1 -1
  5. package/__tests__/form-inside-conditional-onsubmit.test.ts +99 -0
  6. package/__tests__/payment-pay-pages.test.ts +40 -0
  7. package/__tests__/pwa-project-plugin.test.ts +10 -0
  8. package/__tests__/state-data-source-fetch-grouping.test.ts +31 -0
  9. package/__tests__/state-data-source-page-arrival.test.ts +187 -0
  10. package/__tests__/state-data-source-runtime-user-fetch.test.ts +4 -1
  11. package/__tests__/state-data-source-single-collection.test.ts +176 -0
  12. package/dist/cjs/analytics/tracker-source.d.ts +1 -1
  13. package/dist/cjs/analytics/tracker-source.d.ts.map +1 -1
  14. package/dist/cjs/analytics/tracker-source.js +1 -1
  15. package/dist/cjs/analytics/tracker-source.js.map +1 -1
  16. package/dist/cjs/bookings/booking-context-generator.d.ts +12 -0
  17. package/dist/cjs/bookings/booking-context-generator.d.ts.map +1 -0
  18. package/dist/cjs/bookings/booking-context-generator.js +18 -0
  19. package/dist/cjs/bookings/booking-context-generator.js.map +1 -0
  20. package/dist/cjs/bookings/project-plugin.d.ts +14 -0
  21. package/dist/cjs/bookings/project-plugin.d.ts.map +1 -0
  22. package/dist/cjs/bookings/project-plugin.js +101 -0
  23. package/dist/cjs/bookings/project-plugin.js.map +1 -0
  24. package/dist/cjs/dashboard-layout-plugin.d.ts.map +1 -1
  25. package/dist/cjs/dashboard-layout-plugin.js +1 -1
  26. package/dist/cjs/dashboard-layout-plugin.js.map +1 -1
  27. package/dist/cjs/ecommerce/project-plugin.d.ts +14 -0
  28. package/dist/cjs/ecommerce/project-plugin.d.ts.map +1 -1
  29. package/dist/cjs/ecommerce/project-plugin.js +95 -73
  30. package/dist/cjs/ecommerce/project-plugin.js.map +1 -1
  31. package/dist/cjs/entity-mutation-ssr-finalize-plugin.d.ts +4 -1
  32. package/dist/cjs/entity-mutation-ssr-finalize-plugin.d.ts.map +1 -1
  33. package/dist/cjs/entity-mutation-ssr-finalize-plugin.js +8 -4
  34. package/dist/cjs/entity-mutation-ssr-finalize-plugin.js.map +1 -1
  35. package/dist/cjs/forms/form-submission-handler.d.ts.map +1 -1
  36. package/dist/cjs/forms/form-submission-handler.js +11 -0
  37. package/dist/cjs/forms/form-submission-handler.js.map +1 -1
  38. package/dist/cjs/global-state/data-source-utils.d.ts +17 -0
  39. package/dist/cjs/global-state/data-source-utils.d.ts.map +1 -1
  40. package/dist/cjs/global-state/data-source-utils.js +41 -1
  41. package/dist/cjs/global-state/data-source-utils.js.map +1 -1
  42. package/dist/cjs/global-state/project-plugin.d.ts.map +1 -1
  43. package/dist/cjs/global-state/project-plugin.js +9 -2
  44. package/dist/cjs/global-state/project-plugin.js.map +1 -1
  45. package/dist/cjs/index.d.ts +1 -0
  46. package/dist/cjs/index.d.ts.map +1 -1
  47. package/dist/cjs/index.js +57 -53
  48. package/dist/cjs/index.js.map +1 -1
  49. package/dist/cjs/internationalization/locale-mapper-component.d.ts +2 -0
  50. package/dist/cjs/internationalization/locale-mapper-component.d.ts.map +1 -1
  51. package/dist/cjs/internationalization/locale-mapper-component.js +21 -2
  52. package/dist/cjs/internationalization/locale-mapper-component.js.map +1 -1
  53. package/dist/cjs/pwa/network-only-paths.d.ts.map +1 -1
  54. package/dist/cjs/pwa/network-only-paths.js +6 -3
  55. package/dist/cjs/pwa/network-only-paths.js.map +1 -1
  56. package/dist/cjs/state-data-source-plugin.d.ts.map +1 -1
  57. package/dist/cjs/state-data-source-plugin.js +85 -28
  58. package/dist/cjs/state-data-source-plugin.js.map +1 -1
  59. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  60. package/dist/esm/analytics/tracker-source.d.ts +1 -1
  61. package/dist/esm/analytics/tracker-source.d.ts.map +1 -1
  62. package/dist/esm/analytics/tracker-source.js +1 -1
  63. package/dist/esm/analytics/tracker-source.js.map +1 -1
  64. package/dist/esm/bookings/booking-context-generator.d.ts +12 -0
  65. package/dist/esm/bookings/booking-context-generator.d.ts.map +1 -0
  66. package/dist/esm/bookings/booking-context-generator.js +14 -0
  67. package/dist/esm/bookings/booking-context-generator.js.map +1 -0
  68. package/dist/esm/bookings/project-plugin.d.ts +14 -0
  69. package/dist/esm/bookings/project-plugin.d.ts.map +1 -0
  70. package/dist/esm/bookings/project-plugin.js +98 -0
  71. package/dist/esm/bookings/project-plugin.js.map +1 -0
  72. package/dist/esm/dashboard-layout-plugin.d.ts.map +1 -1
  73. package/dist/esm/dashboard-layout-plugin.js +1 -1
  74. package/dist/esm/dashboard-layout-plugin.js.map +1 -1
  75. package/dist/esm/ecommerce/project-plugin.d.ts +14 -0
  76. package/dist/esm/ecommerce/project-plugin.d.ts.map +1 -1
  77. package/dist/esm/ecommerce/project-plugin.js +96 -74
  78. package/dist/esm/ecommerce/project-plugin.js.map +1 -1
  79. package/dist/esm/entity-mutation-ssr-finalize-plugin.d.ts +4 -1
  80. package/dist/esm/entity-mutation-ssr-finalize-plugin.d.ts.map +1 -1
  81. package/dist/esm/entity-mutation-ssr-finalize-plugin.js +8 -4
  82. package/dist/esm/entity-mutation-ssr-finalize-plugin.js.map +1 -1
  83. package/dist/esm/forms/form-submission-handler.d.ts.map +1 -1
  84. package/dist/esm/forms/form-submission-handler.js +11 -0
  85. package/dist/esm/forms/form-submission-handler.js.map +1 -1
  86. package/dist/esm/global-state/data-source-utils.d.ts +17 -0
  87. package/dist/esm/global-state/data-source-utils.d.ts.map +1 -1
  88. package/dist/esm/global-state/data-source-utils.js +38 -0
  89. package/dist/esm/global-state/data-source-utils.js.map +1 -1
  90. package/dist/esm/global-state/project-plugin.d.ts.map +1 -1
  91. package/dist/esm/global-state/project-plugin.js +10 -3
  92. package/dist/esm/global-state/project-plugin.js.map +1 -1
  93. package/dist/esm/index.d.ts +1 -0
  94. package/dist/esm/index.d.ts.map +1 -1
  95. package/dist/esm/index.js +3 -0
  96. package/dist/esm/index.js.map +1 -1
  97. package/dist/esm/internationalization/locale-mapper-component.d.ts +2 -0
  98. package/dist/esm/internationalization/locale-mapper-component.d.ts.map +1 -1
  99. package/dist/esm/internationalization/locale-mapper-component.js +20 -1
  100. package/dist/esm/internationalization/locale-mapper-component.js.map +1 -1
  101. package/dist/esm/pwa/network-only-paths.d.ts.map +1 -1
  102. package/dist/esm/pwa/network-only-paths.js +6 -3
  103. package/dist/esm/pwa/network-only-paths.js.map +1 -1
  104. package/dist/esm/state-data-source-plugin.d.ts.map +1 -1
  105. package/dist/esm/state-data-source-plugin.js +87 -30
  106. package/dist/esm/state-data-source-plugin.js.map +1 -1
  107. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  108. package/package.json +19 -19
  109. package/src/analytics/tracker-source.ts +45 -7
  110. package/src/bookings/booking-context-generator.ts +48 -0
  111. package/src/bookings/project-plugin.ts +53 -0
  112. package/src/dashboard-layout-plugin.ts +5 -0
  113. package/src/ecommerce/project-plugin.ts +101 -70
  114. package/src/entity-mutation-ssr-finalize-plugin.ts +8 -3
  115. package/src/forms/form-submission-handler.ts +10 -0
  116. package/src/global-state/data-source-utils.ts +42 -0
  117. package/src/global-state/project-plugin.ts +12 -3
  118. package/src/index.ts +3 -0
  119. package/src/internationalization/locale-mapper-component.ts +24 -0
  120. package/src/pwa/network-only-paths.ts +7 -3
  121. package/src/state-data-source-plugin.ts +122 -30
@@ -0,0 +1,165 @@
1
+ import { TRACKER_SOURCE } from '../src/analytics/tracker-source'
2
+
3
+ /**
4
+ * Retention only recognises visitors who accepted the cookie banner. The banner
5
+ * is accepted on a page the tracker is already running on, so the tracker must
6
+ * pick the consent up mid-visit — reading it only at boot left the accepting
7
+ * visit (and every client-side navigation after it) anonymous, and a visitor
8
+ * needed a THIRD visit before they could ever count as returning.
9
+ *
10
+ * These tests run the emitted tracker against a stubbed browser and read what
11
+ * it actually sends.
12
+ */
13
+
14
+ type Sent = Record<string, unknown>
15
+
16
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
17
+
18
+ function memoryStorage() {
19
+ const values = new Map<string, string>()
20
+ return {
21
+ getItem: (key: string) => (values.has(key) ? values.get(key) : null),
22
+ setItem: (key: string, value: string) => values.set(key, String(value)),
23
+ removeItem: (key: string) => values.delete(key),
24
+ }
25
+ }
26
+
27
+ function bootTracker() {
28
+ const localStorage = memoryStorage()
29
+ const sessionStorage = memoryStorage()
30
+ const batches: Sent[][] = []
31
+ const heartbeats: Sent[] = []
32
+ const documentListeners: Record<string, () => void> = {}
33
+
34
+ const fakeDocument = {
35
+ visibilityState: 'visible',
36
+ referrer: '',
37
+ documentElement: { scrollHeight: 0 },
38
+ addEventListener: (type: string, listener: () => void) => {
39
+ documentListeners[type] = listener
40
+ },
41
+ querySelector: () => null,
42
+ }
43
+ const fakeWindow = {
44
+ location: { hostname: 'shop.example.com', pathname: '/', search: '' },
45
+ localStorage,
46
+ sessionStorage,
47
+ screen: { width: 1440 },
48
+ innerHeight: 900,
49
+ scrollY: 0,
50
+ addEventListener: () => undefined,
51
+ }
52
+ const fakeFetch = (url: string, init: { body: string }) => {
53
+ const payload = JSON.parse(init.body)
54
+ if (url.endsWith('/heartbeat')) {
55
+ heartbeats.push(payload)
56
+ } else {
57
+ batches.push(payload.events)
58
+ }
59
+ return Promise.resolve({ status: 202 })
60
+ }
61
+
62
+ const body = TRACKER_SOURCE.replace(/^export\s+/gm, '')
63
+ const tracker = new Function(
64
+ 'process',
65
+ 'window',
66
+ 'document',
67
+ 'navigator',
68
+ 'fetch',
69
+ 'setInterval',
70
+ `${body}\nreturn { initTeleportAnalytics, trackRouteChange }`
71
+ )(
72
+ {
73
+ env: {
74
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example.com',
75
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'public-key',
76
+ },
77
+ },
78
+ fakeWindow,
79
+ fakeDocument,
80
+ { webdriver: false },
81
+ fakeFetch,
82
+ () => 0
83
+ )
84
+
85
+ // Hiding the page flushes the queue — the same path a real tab switch takes.
86
+ const flush = (): Sent[] => {
87
+ fakeDocument.visibilityState = 'hidden'
88
+ documentListeners.visibilitychange()
89
+ fakeDocument.visibilityState = 'visible'
90
+ return batches.splice(0).flat()
91
+ }
92
+ // Showing the page again sends a heartbeat straight away.
93
+ const show = () => {
94
+ fakeDocument.visibilityState = 'visible'
95
+ documentListeners.visibilitychange()
96
+ }
97
+ const navigate = (path: string) => {
98
+ fakeWindow.location.pathname = path
99
+ tracker.trackRouteChange(path)
100
+ }
101
+
102
+ return { tracker, localStorage, sessionStorage, heartbeats, flush, show, navigate }
103
+ }
104
+
105
+ describe('tracker — consent given or withdrawn mid-visit', () => {
106
+ it('upgrades the running visit to the persistent id once the banner is accepted', () => {
107
+ const page = bootTracker()
108
+ page.tracker.initTeleportAnalytics({})
109
+ const [landing] = page.flush()
110
+
111
+ expect(landing.visitorId).toBeNull()
112
+ expect(page.localStorage.getItem('tp_aid')).toBeNull()
113
+
114
+ page.localStorage.setItem('cookieConsent', 'accepted')
115
+ page.navigate('/pricing')
116
+ const [next] = page.flush()
117
+
118
+ const visitorId = page.localStorage.getItem('tp_aid')
119
+ expect(visitorId).toMatch(UUID)
120
+ expect(next.visitorId).toBe(visitorId)
121
+ // Same session: the server hands it to the consented visitor instead of
122
+ // counting a second visit.
123
+ expect(next.sessionId).toBe(landing.sessionId)
124
+ expect(page.sessionStorage.getItem('tp_sid')).toBe(landing.sessionId)
125
+ })
126
+
127
+ it('sends the persistent id on heartbeats after the banner is accepted', () => {
128
+ const page = bootTracker()
129
+ page.tracker.initTeleportAnalytics({})
130
+ expect(page.heartbeats[0].visitorId).toBeNull()
131
+
132
+ page.localStorage.setItem('cookieConsent', 'accepted')
133
+ page.show()
134
+
135
+ const lastBeat = page.heartbeats[page.heartbeats.length - 1]
136
+ expect(lastBeat.visitorId).toMatch(UUID)
137
+ expect(lastBeat.visitorId).toBe(page.localStorage.getItem('tp_aid'))
138
+ })
139
+
140
+ it('reuses the stored id for a visitor who accepted on an earlier visit', () => {
141
+ const page = bootTracker()
142
+ page.localStorage.setItem('cookieConsent', 'accepted')
143
+ page.localStorage.setItem('tp_aid', '0f8fad5b-d9cb-469f-a165-70867728950e')
144
+ page.tracker.initTeleportAnalytics({})
145
+
146
+ const [landing] = page.flush()
147
+ expect(landing.visitorId).toBe('0f8fad5b-d9cb-469f-a165-70867728950e')
148
+ })
149
+
150
+ it('drops the persistent id in a fresh anonymous session when consent disappears', () => {
151
+ const page = bootTracker()
152
+ page.localStorage.setItem('cookieConsent', 'accepted')
153
+ page.tracker.initTeleportAnalytics({})
154
+ const [landing] = page.flush()
155
+ expect(landing.visitorId).toMatch(UUID)
156
+
157
+ page.localStorage.removeItem('cookieConsent')
158
+ page.navigate('/contact')
159
+ const [next] = page.flush()
160
+
161
+ expect(next.visitorId).toBeNull()
162
+ expect(next.sessionId).toMatch(UUID)
163
+ expect(next.sessionId).not.toBe(landing.sessionId)
164
+ })
165
+ })
@@ -0,0 +1,96 @@
1
+ import { FileType, ProjectPluginStructure, UIDLBookingSettings } from '@teleporthq/teleport-types'
2
+ import { NextBookingProjectPlugin } from '../src/bookings/project-plugin'
3
+
4
+ /**
5
+ * The booking items' category taxonomy is baked into `booking-context.js` and
6
+ * read through `useBookingCategories()` — the booking page's category filter is
7
+ * an array mapper over it, as the products' and the blog's are over theirs.
8
+ */
9
+
10
+ const PAGE_WITHOUT_HOOK = 'export default function Book() { return null }'
11
+ const PAGE_WITH_HOOK = [
12
+ "import { useBookingCategories } from '@/booking-context'",
13
+ 'export default function Book() { return null }',
14
+ ].join('\n')
15
+
16
+ const buildStructure = (params: {
17
+ pageContent: string
18
+ bookingSettings?: UIDLBookingSettings
19
+ }): ProjectPluginStructure => {
20
+ const files = new Map<string, { path: string[]; files: Array<Record<string, unknown>> }>()
21
+ files.set('book', {
22
+ path: ['pages'],
23
+ files: [{ name: 'book', fileType: FileType.JS, content: params.pageContent }],
24
+ })
25
+ return {
26
+ uidl: {
27
+ globals: { env: {} },
28
+ ...(params.bookingSettings ? { bookingSettings: params.bookingSettings } : {}),
29
+ },
30
+ files,
31
+ dependencies: {},
32
+ devDependencies: {},
33
+ } as unknown as ProjectPluginStructure
34
+ }
35
+
36
+ const findContext = (structure: ProjectPluginStructure) =>
37
+ structure.files.get('booking-context')?.files?.[0] as { content: string } | undefined
38
+
39
+ describe('NextBookingProjectPlugin', () => {
40
+ it('bakes the taxonomy behind a memoized, locale-aware hook', async () => {
41
+ const structure = buildStructure({
42
+ pageContent: PAGE_WITHOUT_HOOK,
43
+ bookingSettings: {
44
+ categories: [
45
+ {
46
+ id: 'rooms',
47
+ name: 'Rooms',
48
+ slug: 'rooms',
49
+ parentId: null,
50
+ order: 0,
51
+ children: [],
52
+ translations: { es: { name: 'Habitaciones' } },
53
+ },
54
+ ],
55
+ },
56
+ })
57
+
58
+ await new NextBookingProjectPlugin().runAfter(structure)
59
+
60
+ const context = findContext(structure)
61
+ expect(context).toBeDefined()
62
+ expect(context!.content).toContain('export const useBookingCategories')
63
+ expect(context!.content).toContain(
64
+ 'resolveCategoryTranslations(BOOKING_CATEGORIES, router.locale)'
65
+ )
66
+ expect(context!.content).toContain('"Habitaciones"')
67
+ expect(context!.content).not.toContain('createContext')
68
+ })
69
+
70
+ it('emits an EMPTY taxonomy for bookings that have no categories yet', async () => {
71
+ const structure = buildStructure({
72
+ pageContent: PAGE_WITHOUT_HOOK,
73
+ bookingSettings: { categories: [] },
74
+ })
75
+
76
+ await new NextBookingProjectPlugin().runAfter(structure)
77
+
78
+ expect(findContext(structure)!.content).toContain('const BOOKING_CATEGORIES = []')
79
+ })
80
+
81
+ it('backstops a dangling import when the project carries no bookingSettings', async () => {
82
+ const structure = buildStructure({ pageContent: PAGE_WITH_HOOK })
83
+
84
+ await new NextBookingProjectPlugin().runAfter(structure)
85
+
86
+ expect(findContext(structure)!.content).toContain('const BOOKING_CATEGORIES = []')
87
+ })
88
+
89
+ it('leaves a project that never references the hook untouched', async () => {
90
+ const structure = buildStructure({ pageContent: PAGE_WITHOUT_HOOK })
91
+
92
+ await new NextBookingProjectPlugin().runAfter(structure)
93
+
94
+ expect(findContext(structure)).toBeUndefined()
95
+ })
96
+ })
@@ -167,7 +167,9 @@ describe('a content preset category page, generated', () => {
167
167
  `import \\{[\\s\\S]*\\b${RESOLVER}\\b[\\s\\S]*\\} from '${MODULE.replace('/', '\\/')}'`
168
168
  )
169
169
  )
170
- expect(page).toMatch(new RegExp(`await ${RESOLVER}\\(\\{[\\s\\S]*slug: params\\['slug'\\]`))
170
+ expect(page).toMatch(
171
+ new RegExp(`await ${RESOLVER}\\(\\{[\\s\\S]*slug: params\\?\\.\\['slug'\\]`)
172
+ )
171
173
  expect(page).toMatch(
172
174
  /if \(!response\?\.data\) \{\s*return \{\s*notFound: true,\s*revalidate: 60/
173
175
  )
@@ -232,7 +232,7 @@ describe('Next generator — Custom Pages', () => {
232
232
  it('server-fetches the published row by the slug route param and 404s when it is missing', () => {
233
233
  const { page } = generated
234
234
  expect(page).toContain('export async function getStaticProps')
235
- expect(page).toMatch(/source:\s*'slug',\s*destination:\s*params\['slug'\]/)
235
+ expect(page).toMatch(/source:\s*'slug',\s*destination:\s*params\?\.\['slug'\]/)
236
236
  expect(page).toMatch(/source:\s*'status',\s*destination:\s*'published'/)
237
237
  expect(page).toContain('notFound: true')
238
238
  })
@@ -0,0 +1,99 @@
1
+ import { GeneratedFolder, ProjectUIDL, UIDLFormDefinition } from '@teleporthq/teleport-types'
2
+ import uidlSample from '../../../examples/test-samples/project-sample.json'
3
+ import { createNextProjectGenerator } from '../src'
4
+ import NextTemplate from '../src/project-template'
5
+
6
+ /**
7
+ * A platform form rendered inside a rendering condition (a results step of a
8
+ * multi-step page: `{activeStep === 7 && (<form data-form-id=…/>)}`) must
9
+ * still receive the generated onSubmit handler. It did not: the handler was
10
+ * declared but never attached, so a workflow's `form.requestSubmit()` posted
11
+ * the form natively and reloaded the page, losing every state value.
12
+ */
13
+
14
+ const template = JSON.parse(JSON.stringify(NextTemplate)) as GeneratedFolder
15
+ const FORM_ID = 'results-form'
16
+
17
+ const formElement = () => ({
18
+ type: 'element',
19
+ content: {
20
+ elementType: 'form',
21
+ name: 'results',
22
+ attrs: { 'data-form-id': { type: 'static', content: FORM_ID } },
23
+ children: [
24
+ {
25
+ type: 'element',
26
+ content: {
27
+ elementType: 'textinput',
28
+ name: 'email',
29
+ attrs: {
30
+ name: { type: 'static', content: 'email' },
31
+ type: { type: 'static', content: 'email' },
32
+ },
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ })
38
+
39
+ const formDefinition = (): UIDLFormDefinition => ({
40
+ id: { type: 'static', content: FORM_ID },
41
+ name: { type: 'static', content: 'Results' },
42
+ formNodeId: { type: 'static', content: 'form-node' },
43
+ fields: {
44
+ email: {
45
+ id: { type: 'static', content: 'email' },
46
+ name: { type: 'static', content: 'email' },
47
+ nodeId: { type: 'static', content: 'email-node' },
48
+ type: 'textinput',
49
+ },
50
+ },
51
+ behaviors: { onSuccess: { action: 'clear-form' }, onError: { action: 'clear-form' } },
52
+ })
53
+
54
+ const buildUidl = (): ProjectUIDL => {
55
+ const uidl = JSON.parse(JSON.stringify(uidlSample)) as ProjectUIDL
56
+ const indexPage = (uidl.root.node.content.children || []).find(
57
+ (child) =>
58
+ child.type === 'conditional' && (child.content as { value?: string }).value === 'index'
59
+ )
60
+ const pageElement = (indexPage as { content: { node: { content: { children: unknown[] } } } })
61
+ .content.node.content
62
+ const root = uidl.root as { stateDefinitions?: Record<string, unknown> }
63
+ root.stateDefinitions = {
64
+ ...(root.stateDefinitions || {}),
65
+ activeStep: { type: 'number', defaultValue: 1 },
66
+ }
67
+ pageElement.children.push({
68
+ type: 'conditional',
69
+ content: {
70
+ reference: { type: 'dynamic', content: { referenceType: 'state', id: 'activeStep' } },
71
+ value: 7,
72
+ node: formElement(),
73
+ },
74
+ })
75
+ uidl.forms = {
76
+ items: { [FORM_ID]: formDefinition() },
77
+ formsServerUrl: { type: 'env', content: 'NEXT_PUBLIC_FORMS_SERVER_URL' },
78
+ globalConfig: {
79
+ captchaProvider: 'recaptcha',
80
+ defaultCaptchaPublicKey: { type: 'env', content: 'NEXT_PUBLIC_CAPTCHA_PUBLIC_KEY' },
81
+ },
82
+ } as ProjectUIDL['forms']
83
+ return uidl
84
+ }
85
+
86
+ const indexPageOf = (folder: GeneratedFolder) =>
87
+ folder.subFolders.find((sub) => sub.name === 'pages')?.files.find((file) => file.name === 'index')
88
+ ?.content ?? ''
89
+
90
+ describe('Next forms: a form inside a rendering condition keeps its submit handler', () => {
91
+ it('attaches onSubmit to the form rendered under `activeStep === 7`', async () => {
92
+ const page = indexPageOf(
93
+ await createNextProjectGenerator().generateProject(buildUidl(), template)
94
+ )
95
+ expect(page).toContain('activeStep === 7')
96
+ expect(page).toContain(`data-form-id="${FORM_ID}"`)
97
+ expect(page).toMatch(/onSubmit=\{handleFormSubmit_results_form\}/)
98
+ })
99
+ })
@@ -749,4 +749,44 @@ describe('what the store generator emits for its providers', () => {
749
749
  STRIPE_WEBHOOK_SECRET: '',
750
750
  })
751
751
  })
752
+
753
+ it('gives a site paid without a store (its bookings) the credentials, capture and confirmation', async () => {
754
+ const structure = build([])
755
+ delete (structure.uidl as any).ecommerceSettings
756
+ ;(structure.uidl as any).paymentSettings = {
757
+ paymentProviders: [
758
+ { type: 'paypal', credentials: { clientId: 'CONFIGURATION_PAYPAL_CLIENT_ID' } },
759
+ { type: 'razorpay', credentials: { keyId: 'CONFIGURATION_RAZORPAY_KEY_ID' } },
760
+ ],
761
+ }
762
+ ;(structure.uidl as any).workflows = {
763
+ workflows: {
764
+ paypalHook: {
765
+ name: 'PayPal Payment Webhook',
766
+ trigger: { type: 'event-webhook-received' },
767
+ webhookConfig: { urlPath: '/api/webhooks/paypal-payment' },
768
+ },
769
+ },
770
+ }
771
+ await new NextEcommerceProjectPlugin().runAfter(structure)
772
+ expect(structure.files.has('ecommerce-api-paypal-capture')).toBe(true)
773
+ expect(structure.files.has('payment-page-razorpay')).toBe(true)
774
+ expect(structure.files.has('ecommerce-api-payment-confirm')).toBe(true)
775
+ expect(structure.uidl.globals.env).toMatchObject({
776
+ PAYPAL_CLIENT_ID: 'teleporthq.secrets.CONFIGURATION_PAYPAL_CLIENT_ID',
777
+ RAZORPAY_KEY_ID: 'teleporthq.secrets.CONFIGURATION_RAZORPAY_KEY_ID',
778
+ })
779
+ // No store: no store runtime.
780
+ expect(structure.files.has('ecommerce-api-settings')).toBe(false)
781
+ expect(structure.files.has('ecommerce-api-cart')).toBe(false)
782
+ })
783
+
784
+ it('emits nothing for a site with neither a store nor payment settings', async () => {
785
+ const structure = build([])
786
+ delete (structure.uidl as any).ecommerceSettings
787
+ await new NextEcommerceProjectPlugin().runAfter(structure)
788
+ expect(structure.files.has('ecommerce-api-payment-confirm')).toBe(false)
789
+ expect(structure.files.has('payment-drivers')).toBe(false)
790
+ expect(structure.uidl.globals.env).toEqual({})
791
+ })
752
792
  })
@@ -322,6 +322,16 @@ describe('NextPwaProjectPlugin — installable app', () => {
322
322
  expect(clientConfig(structure).networkOnly).toEqual(workerConfig(structure).networkOnly)
323
323
  })
324
324
 
325
+ it('keeps the payment pages off the cache on a site paid for its bookings alone', async () => {
326
+ const paid = await run({
327
+ pwa: installableApp(),
328
+ paymentSettings: { paymentProviders: [{ type: 'razorpay' }] },
329
+ })
330
+ expect(workerConfig(paid).networkOnly).toContain('/pay')
331
+ const unpaid = await run({ pwa: installableApp() })
332
+ expect(workerConfig(unpaid).networkOnly).not.toContain('/pay')
333
+ })
334
+
325
335
  it('precaches nothing when the home page is private or the site is password protected', async () => {
326
336
  const privateHome = await run({ pwa: installableApp({ networkOnlyPaths: ['/'] }) })
327
337
  expect(workerConfig(privateHome).precacheUrls).toEqual([])
@@ -118,6 +118,37 @@ describe('state-data-source-plugin fetch grouping', () => {
118
118
  expect(listProp![1]).toBe(mirrorProp![1])
119
119
  })
120
120
 
121
+ it('keeps a fetch another plugin pushed into the declaration when it rebuilds it', async () => {
122
+ // The pagination plugin adds a paginated list's page fetch straight into
123
+ // the `const [...] = await Promise.all([...])` this plugin built; a later
124
+ // state registration rebuilt that declaration from its own bookkeeping
125
+ // and the list's fetch was gone — its props then read an undefined name.
126
+ const structure = makeStructure({
127
+ productsList: makeState('SELECT * FROM products ORDER BY sku ASC LIMIT 200'),
128
+ })
129
+ const first = await plugin(structure as any)
130
+ const chunk = first.chunks.find((c: any) => c.name === 'getStaticProps') as any
131
+ const declaration = chunk.meta.parallelFetchData.declaration as types.VariableDeclaration
132
+ const declarator = declaration.declarations[0]
133
+ ;(declarator.id as types.ArrayPattern).elements.push(types.identifier('Teleport_products_ds_0'))
134
+ ;(
135
+ (declarator.init as types.AwaitExpression).argument as types.CallExpression
136
+ ).arguments[0].elements.push(
137
+ types.callExpression(types.identifier('fetchProductsPage'), [types.numericLiteral(1)])
138
+ )
139
+
140
+ structure.uidl.stateDefinitions.lowStockAlerts = makeState()
141
+ const second = await plugin(structure as any)
142
+
143
+ const code = getStaticPropsCode(second as any)
144
+ expect(code.match(/Promise\.all/g)).toHaveLength(1)
145
+ expect(code).toContain('Teleport_products_ds_0')
146
+ expect(code).toContain('fetchProductsPage(1)')
147
+ const names = (second.chunks.find((c: any) => c.name === 'getStaticProps') as any).meta
148
+ .parallelFetchData.names
149
+ expect(new Set(names).size).toBe(names.length)
150
+ })
151
+
121
152
  it('separates a query-less binding from a queried one on the same table', async () => {
122
153
  // Products page shape: productsList has a rawQuery, lowStockAlerts is a
123
154
  // bare binding (whole table) — they must not share the LIMIT 200 fetch.
@@ -0,0 +1,187 @@
1
+ import generator from '@babel/generator'
2
+ import * as types from '@babel/types'
3
+ import { ChunkType } from '@teleporthq/teleport-types'
4
+ import { createStateDataSourcePlugin } from '../src/state-data-source-plugin'
5
+ import { createEntityMutationSsrFinalizerPlugin } from '../src/entity-mutation-ssr-finalize-plugin'
6
+
7
+ // A page state bound to a data source must hold the values computed for THIS
8
+ // arrival. Seeded from getStaticProps it did not: a client-side navigation got
9
+ // the JSON Next.js had cached in the browser (or prefetched with the link), ISR
10
+ // served the copy rendered before the last write, and a navigation to the page
11
+ // already on screen kept the old `useState` value whatever the new props said.
12
+ // Such a page now renders per request and remounts on every arrival.
13
+
14
+ const DS_ID = 'a784e6dd-ddc9-4d12-b9d2-137d285e313a'
15
+ const FETCHER_FILE_NAME = 'teleport-customers-a784e6dd'
16
+
17
+ const boundState = (query?: string) => ({
18
+ type: 'array',
19
+ defaultValue: [] as unknown[],
20
+ dataSourceBinding: { dataSourceId: DS_ID, refPath: ['customers'] },
21
+ ...(query ? { query } : {}),
22
+ })
23
+
24
+ const makeStructure = (stateDefinitions: Record<string, unknown>) => ({
25
+ uidl: {
26
+ name: 'Customers',
27
+ stateDefinitions,
28
+ node: { type: 'element', content: { elementType: 'container' } },
29
+ outputOptions: { folderPath: [] as string[] },
30
+ },
31
+ chunks: [
32
+ {
33
+ type: 'AST',
34
+ fileType: 'js',
35
+ name: 'jsx-component',
36
+ linkAfter: [] as string[],
37
+ content: types.variableDeclaration('const', [
38
+ types.variableDeclarator(
39
+ types.identifier('Customers'),
40
+ types.arrowFunctionExpression(
41
+ [types.identifier('props')],
42
+ types.blockStatement([types.returnStatement(types.nullLiteral())])
43
+ )
44
+ ),
45
+ ]),
46
+ },
47
+ {
48
+ type: 'AST',
49
+ fileType: 'js',
50
+ name: 'export',
51
+ linkAfter: ['jsx-component'],
52
+ content: types.exportDefaultDeclaration(types.identifier('Customers')),
53
+ },
54
+ ] as any[],
55
+ dependencies: {} as Record<string, unknown>,
56
+ options: {
57
+ pagesPath: ['pages'],
58
+ auth: { provider: 'teleport' },
59
+ dataSources: { [DS_ID]: { id: DS_ID, type: 'teleport', config: {} } },
60
+ // Pre-extracted fetcher, so the test does not depend on its generation.
61
+ extractedResources: {
62
+ [`utils/${FETCHER_FILE_NAME}`]: {
63
+ fileName: FETCHER_FILE_NAME,
64
+ fileType: 'js',
65
+ path: ['utils', 'data-sources'],
66
+ content: '// stub',
67
+ },
68
+ } as Record<string, any>,
69
+ },
70
+ })
71
+
72
+ const runPagePipeline = async (stateDefinitions: Record<string, unknown>) => {
73
+ const structure = makeStructure(stateDefinitions)
74
+ await createStateDataSourcePlugin()(structure as any)
75
+ await createEntityMutationSsrFinalizerPlugin()(structure as any)
76
+ const chunkCode = (name: string) => {
77
+ const chunk = structure.chunks.find((candidate) => candidate.name === name)
78
+ return chunk ? generator(chunk.content as types.Node).code : null
79
+ }
80
+ return { structure, chunkCode }
81
+ }
82
+
83
+ describe('page states seeded from a data source', () => {
84
+ it('render per request and remount the page on every arrival', async () => {
85
+ const { structure, chunkCode } = await runPagePipeline({
86
+ customersRows: boundState('SELECT * FROM customers ORDER BY created_at DESC LIMIT 20'),
87
+ })
88
+
89
+ expect(chunkCode('getStaticProps')).toBeNull()
90
+ const serverProps = chunkCode('getServerSideProps')
91
+ expect(serverProps).toContain('export async function getServerSideProps(context)')
92
+ expect(serverProps).toContain('customersRows:')
93
+ expect(serverProps).not.toContain('revalidate')
94
+
95
+ expect(chunkCode('export')).toBe('export default remountOnPageArrival(Customers);')
96
+ expect(structure.dependencies.remountOnPageArrival).toEqual({
97
+ type: 'local',
98
+ path: '../utils/page-arrival',
99
+ })
100
+ expect(structure.options.extractedResources['utils/page-arrival']).toBeDefined()
101
+ })
102
+
103
+ it('remount the page for states fetched in the browser, which stays statically generated', async () => {
104
+ const { structure, chunkCode } = await runPagePipeline({
105
+ invoicesRows: boundState(
106
+ "SELECT * FROM app_invoices WHERE customer_id = '{{Current User.id}}' LIMIT 20"
107
+ ),
108
+ })
109
+
110
+ expect(chunkCode('getServerSideProps')).toBeNull()
111
+ expect(chunkCode('jsx-component')).toContain('/api/page-state/')
112
+ expect(chunkCode('export')).toBe('export default remountOnPageArrival(Customers);')
113
+ expect(structure.options.extractedResources['utils/page-arrival']).toBeDefined()
114
+ })
115
+
116
+ it('leave the page alone when no state can be seeded', async () => {
117
+ const { structure, chunkCode } = await runPagePipeline({
118
+ customerRow: boundState("SELECT * FROM customers WHERE id = '{{urlDifferentiator}}'"),
119
+ })
120
+
121
+ expect(chunkCode('getStaticProps')).toBeNull()
122
+ expect(chunkCode('getServerSideProps')).toBeNull()
123
+ expect(chunkCode('export')).toBe('export default Customers;')
124
+ expect(structure.dependencies.remountOnPageArrival).toBeUndefined()
125
+ expect(structure.options.extractedResources['utils/page-arrival']).toBeUndefined()
126
+ })
127
+ })
128
+
129
+ describe('getServerSideProps finalizer — data-source states', () => {
130
+ const getStaticPropsChunk = () => {
131
+ const fn = types.functionDeclaration(
132
+ types.identifier('getStaticProps'),
133
+ [types.identifier('context')],
134
+ types.blockStatement([
135
+ types.returnStatement(
136
+ types.objectExpression([
137
+ types.objectProperty(types.identifier('props'), types.objectExpression([])),
138
+ types.objectProperty(types.identifier('revalidate'), types.numericLiteral(1)),
139
+ ])
140
+ ),
141
+ ]),
142
+ false,
143
+ true
144
+ )
145
+ return {
146
+ name: 'getStaticProps',
147
+ type: ChunkType.AST,
148
+ content: types.exportNamedDeclaration(fn),
149
+ }
150
+ }
151
+
152
+ const finalize = async (
153
+ stateDefinitions: Record<string, unknown>,
154
+ projectDataSources: object
155
+ ) => {
156
+ const structure: any = {
157
+ uidl: { name: 'Customers', node: { type: 'element', content: {} }, stateDefinitions },
158
+ chunks: [getStaticPropsChunk()],
159
+ dependencies: {},
160
+ options: { dataSources: projectDataSources },
161
+ }
162
+ await createEntityMutationSsrFinalizerPlugin()(structure)
163
+ return structure.chunks[0].name as string
164
+ }
165
+
166
+ const dataSources = { [DS_ID]: { id: DS_ID, type: 'teleport', config: {} } }
167
+
168
+ it('converts a page that seeds a state from a data source', async () => {
169
+ expect(await finalize({ rows: boundState('SELECT 1 AS value') }, dataSources)).toBe(
170
+ 'getServerSideProps'
171
+ )
172
+ expect(await finalize({ rows: boundState() }, dataSources)).toBe('getServerSideProps')
173
+ })
174
+
175
+ it('keeps static generation when the bound state is only resolved in the browser', async () => {
176
+ expect(
177
+ await finalize(
178
+ { rows: boundState("SELECT * FROM t WHERE user_id = '{{Current User.id}}'") },
179
+ dataSources
180
+ )
181
+ ).toBe('getStaticProps')
182
+ })
183
+
184
+ it('keeps static generation when the bound data source is not in the project', async () => {
185
+ expect(await finalize({ rows: boundState('SELECT 1 AS value') }, {})).toBe('getStaticProps')
186
+ })
187
+ })
@@ -109,7 +109,10 @@ const runPageStateRoute = async (
109
109
  throw new Error('unexpected require: ' + name)
110
110
  }
111
111
  const code = routeSource
112
- .replace("import { Client } from 'pg'", "const { Client } = require('pg')")
112
+ .replace(
113
+ "import { Client, types as pgTypes } from 'pg'",
114
+ "const { Client, types: pgTypes } = require('pg')"
115
+ )
113
116
  .replace(/^export default async function handler/m, 'async function handler')
114
117
  .concat('\nmodule.exports = handler')
115
118
  const moduleObj: { exports: any } = { exports: {} }