import { parse } from '@babel/parser'
import { FileType, ProjectPluginStructure, UIDLPluginRecord } from '@teleporthq/teleport-types'
import { NextItemComparisonProjectPlugin } from '../src/item-comparison/project-plugin'
import { resolveItemComparisonConfig } from '../src/item-comparison/settings'
const APP_CONTENT = `const MyApp = ({ Component, pageProps }) => {
return (
)
}
export default MyApp
`
const routeValue = (navLink: string, tableName?: string) => ({
value: navLink,
pageOptions: {
navLink,
...(tableName
? {
dynamicRouteAttribute: 'slug',
detailsPageInfo: {
dataSourceId: 'ds',
dataSourceName: 'Teleport',
dataSourceType: 'teleport',
tableName,
differentiatorColumn: 'slug',
featureIdentifier: '',
},
}
: {}),
},
})
function makeStructure(
record: Partial | null,
features: { store?: boolean; bookings?: boolean } = {}
) {
const files = new Map()
files.set('_app', {
path: ['pages'],
files: [{ name: '_app', fileType: FileType.JS, content: APP_CONTENT }],
})
return {
uidl: {
name: 'p',
globals: { settings: { title: 'P', language: 'en' }, assets: [] },
dataSources: { ds: { id: 'ds', name: 'Teleport', type: 'teleport', config: {} } },
...(features.store ? { ecommerceSettings: { paymentProviders: [] } } : {}),
...(features.bookings ? { bookingSettings: {} } : {}),
...(record ? { plugins: { itemComparison: { settings: {}, ...record } } } : {}),
root: {
name: 'App',
stateDefinitions: {
route: {
type: 'string',
values: [
routeValue('/'),
...(features.store ? [routeValue('/products/[slug]', 'teleport_products')] : []),
...(features.bookings ? [routeValue('/book/[slug]', 'teleport_booking_items')] : []),
],
},
},
},
},
files,
dependencies: {},
devDependencies: {},
template: { files: [], subFolders: [] },
} as unknown as ProjectPluginStructure
}
const parses = (source: string) => parse(source, { sourceType: 'module', plugins: ['jsx'] })
/** The route's cell helpers, as the generated file holds them. */
const routeCells = (source: string) =>
// eslint-disable-next-line no-new-func
new Function(
`${source.slice(
source.indexOf('const MAX_ITEMS'),
source.indexOf('function itemUrl(')
)}; return { priceCell, cell }`
)()
/** A product with variants, as the storefront's product transform hands it to the route. */
const VARIANT_PRODUCT = {
price: 199,
displayPrice: '199.00',
defaultVariantPrice: '129.00',
defaultVariantDisplayPrice: '129.00',
currencySymbol: 'lei',
currency: 'RON',
}
const FLAT_PRODUCT = {
price: 149,
displayPrice: '149.00',
defaultVariantPrice: '',
defaultVariantDisplayPrice: '',
currencySymbol: 'lei',
currency: 'RON',
}
describe('NextItemComparisonProjectPlugin', () => {
it('leaves a project without the plugin exactly as it was', async () => {
const structure = makeStructure(null, { store: true })
await new NextItemComparisonProjectPlugin().runAfter(structure)
expect(structure.files.get('item-comparison-component')).toBeUndefined()
expect(structure.files.get('item-comparison-route')).toBeUndefined()
expect(structure.files.get('_app').files[0].content).toBe(APP_CONTENT)
})
it('emits the comparison runtime and a compare route per comparable kind, with the chosen attributes', async () => {
const structure = makeStructure(
{
settings: {
kinds: ['products', 'bookingItems'],
maxItems: 9,
productAttributes: ['price', 'stock', 'nonsense'],
onlyDifferences: true,
},
pages: { compare: '/compare' },
},
{ store: true, bookings: true }
)
await new NextItemComparisonProjectPlugin().runAfter(structure)
const component = structure.files.get('item-comparison-component').files[0].content as string
expect(component).toContain('"maxItems":6')
// The controls, the tray and the page are the project's own; the runtime answers their workflows.
expect(component).toContain('"workflow:custom:"')
expect(component).toContain('"tq-compare-items"')
expect(component).toContain('"tq-compare-request"')
expect(component).toContain('"tq-compare-toggle:"')
expect(component).not.toContain('createPortal')
expect(component).toContain('"onlyDifferences":true')
expect(component).toContain('"compareRoute":"/compare"')
expect(component).toContain('[data-tq-compare-toggle]')
// The page and the tray learn whether the rows are still on their way.
expect(component).toContain("loading: loading ? 'true' : 'false'")
expect(() => parses(component)).not.toThrow()
expect(structure.files.get('_app').files[0].content).toContain('')
const route = structure.files.get('item-comparison-route')
expect(route.path).toEqual(['pages', 'api', 'plugins'])
expect(route.files[0].name).toBe('compare')
const source = route.files[0].content as string
expect(source).toContain('"products": {')
expect(source).toContain('"bookingItems": {')
expect(source).toContain(
'{"key":"price","label":"Price","better":"low"},{"key":"stock","label":"In stock","better":"high"}'
)
expect(source).not.toContain('nonsense')
expect(source).toContain('"staticBase":"/book","column":"slug"')
expect(source).not.toContain('/admin/booking-items')
expect(source).toContain('activeColumn: "is_active"')
// Read as a visitor: the table route's own row policy decides what is public.
expect(source).toContain('fetch: (params) => readAsVisitor(fetcher0, params)')
expect(source).toContain('async function readAsVisitor(fetcher, params)')
expect(source).not.toContain('.fetchData')
expect(source).toContain('Object.prototype.hasOwnProperty.call(KINDS, kind)')
expect(source).toContain('const MAX_ITEMS = 6')
expect(() => parses(source)).not.toThrow()
expect(
[...structure.files.keys()].filter((key) => key.startsWith('resource-utils/')).length
).toBe(2)
expect(structure.dependencies.pg).toBeDefined()
})
it('reads the kinds from the record and the defaults for the rest', () => {
const structure = makeStructure(
{ settings: { kinds: ['products', 'bookingItems'] } },
{ bookings: true }
)
const config = resolveItemComparisonConfig(structure.uidl)!
expect(config.kinds).toEqual(['products', 'bookingItems'])
expect(config.maxItems).toBe(4)
expect(config.bookingAttributes).toEqual(['price', 'unit', 'capacity', 'duration', 'category'])
expect(config.compareRoute).toBeNull()
// Every custom property makes a row unless the owner says otherwise.
expect(config.productPropertyRows).toBe('all')
expect(config.bookingPropertyRows).toBe('all')
expect(config.productProperties).toEqual([])
})
it('keeps the picked custom property names, each once, and refuses an unknown row mode', () => {
const structure = makeStructure(
{
settings: {
productPropertyRows: 'chosen',
productProperties: [' Material ', 'material', 'Color', 7 as unknown as string, ''],
bookingPropertyRows: 'sometimes',
},
},
{ store: true }
)
const config = resolveItemComparisonConfig(structure.uidl)!
expect(config.productPropertyRows).toBe('chosen')
expect(config.productProperties).toEqual(['Material', 'Color'])
expect(config.bookingPropertyRows).toBe('all')
})
it('answers each item’s shown custom properties and lays them out as rows, the tray folding on phones', async () => {
const structure = makeStructure(
{
settings: {
kinds: ['products', 'bookingItems'],
bookingPropertyRows: 'chosen',
bookingProperties: ['Size'],
onlyDifferences: true,
},
},
{ store: true, bookings: true }
)
await new NextItemComparisonProjectPlugin().runAfter(structure)
const route = structure.files.get('item-comparison-route').files[0].content as string
const routeHelpers = route.slice(
route.indexOf('function text(value)'),
route.indexOf('function itemUrl(')
)
// eslint-disable-next-line no-new-func
const shownProperties = new Function(`${routeHelpers}; return shownProperties`)()
expect(
shownProperties('products', {
metadata: JSON.stringify({
Material: 'Cotton',
Color: 'Navy',
Secret: 'x',
sku: 'A-1',
Empty: ' ',
_tqPropVisibility: JSON.stringify({ Secret: false }),
}),
})
).toEqual([
{ key: 'Material', value: 'Cotton' },
{ key: 'Color', value: 'Navy' },
])
expect(
shownProperties('bookingItems', {
properties: JSON.stringify([
{ key: 'Size', value: '30 m²', visible: true },
{ key: 'Code', value: 'R1', visible: false },
{ key: '', value: 'nameless' },
]),
})
).toEqual([{ key: 'Size', value: '30 m²' }])
expect(shownProperties('bookingItems', { properties: 'not json' })).toEqual([])
const component = structure.files.get('item-comparison-component').files[0].content as string
const settings = component.slice(
component.indexOf('const SETTINGS = '),
component.indexOf('\n', component.indexOf('const SETTINGS = '))
)
const rowsSource = component.slice(
component.indexOf('function customRowsOf('),
component.indexOf('function Toast(')
)
// eslint-disable-next-line no-new-func
const customRowsOf = new Function(
`${settings}; const MAX_CUSTOM_ROWS = 30; ${rowsSource}; return customRowsOf`
)()
const products = {
kind: 'products',
items: [
{
properties: [
{ key: 'Material', value: 'Cotton' },
{ key: 'Fit', value: 'Slim' },
],
},
{
properties: [
{ key: 'material', value: 'Wool' },
{ key: 'Fit', value: 'Slim' },
],
},
],
}
// Every name, once whatever its case; the row every item agrees on is left out.
expect(customRowsOf(products)).toEqual([
{ key: 'material', label: 'Material', cells: [{ text: 'Cotton' }, { text: 'Wool' }] },
])
// Picked names only, in their order; an item without one leaves its cell empty.
expect(
customRowsOf({
kind: 'bookingItems',
items: [{ properties: [{ key: 'Size', value: '30 m²' }] }, { properties: [] }],
})
).toEqual([{ key: 'size', label: 'Size', cells: [{ text: '30 m²' }, { text: '' }] }])
expect(component).toContain('"tq-compare-fold"')
expect(component).toContain('"tq-compare-unfold"')
expect(component).toContain('"(max-width: 767px)"')
expect(component).toContain('trayOpen: trayOpen()')
expect(() => parses(component)).not.toThrow()
})
it('links a booking item to the public page when the admin editor is listed first', async () => {
const structure = makeStructure({ settings: { kinds: ['bookingItems'] } }, { bookings: true })
const values = (
structure.uidl as { root: { stateDefinitions: { route: { values: object[] } } } }
).root.stateDefinitions.route.values
values.unshift({
value: '/admin/booking-items/update/[id]',
pageOptions: {
navLink: '/admin/booking-items/update/[id]',
dynamicRouteAttribute: 'id',
detailsPageInfo: {
dataSourceId: 'ds',
dataSourceName: 'Teleport',
dataSourceType: 'teleport',
tableName: 'teleport_booking_items',
differentiatorColumn: 'id',
featureIdentifier: 'teleportBookingItem',
},
},
})
await new NextItemComparisonProjectPlugin().runAfter(structure)
const source = structure.files.get('item-comparison-route').files[0].content as string
expect(source).toContain('"staticBase":"/book","column":"slug"')
expect(source).not.toContain('/admin/booking-items')
})
describe('the price cell', () => {
const routeWith = async (data?: Record) => {
const structure = makeStructure(
{ settings: { kinds: ['products'] }, ...(data ? { data } : {}) },
{ store: true }
)
await new NextItemComparisonProjectPlugin().runAfter(structure)
return routeCells(structure.files.get('item-comparison-route').files[0].content as string)
}
it('quotes the price the item card shows: the default variant before the stored price', async () => {
const { priceCell } = await routeWith({ priceSymbolSide: 'before' })
expect(priceCell(VARIANT_PRODUCT)).toEqual({ text: 'lei 129.00', number: 129 })
expect(priceCell(FLAT_PRODUCT)).toEqual({ text: 'lei 149.00', number: 149 })
})
it('makes the cheaper default variant the number the best value is read from', async () => {
const { cell } = await routeWith({ priceSymbolSide: 'before' })
const prices = [VARIANT_PRODUCT, FLAT_PRODUCT].map((row) =>
cell('products', 'price', row)
) as Array<{ number: number }>
expect(Math.min(...prices.map((price) => price.number))).toBe(129)
})
it('quotes the discounted, tax-inclusive price, never the stored net one', async () => {
const { priceCell } = await routeWith()
expect(
priceCell({ ...FLAT_PRODUCT, price: 100, displayPrice: '107.10', currencySymbol: '$' })
).toEqual({ text: '$ 107.10', number: 107.1 })
})
it('writes the symbol on the side the site writes it', async () => {
const { priceCell } = await routeWith({ priceSymbolSide: 'after' })
expect(priceCell({ ...FLAT_PRODUCT, currencySymbol: 'kr' }).text).toBe('149.00 kr')
})
it('reads a bookable item, which has only its stored price', async () => {
const { priceCell } = await routeWith({ priceSymbolSide: 'before' })
expect(priceCell({ price: '80', currency: 'EUR' })).toEqual({
text: 'EUR 80.00',
number: 80,
})
expect(priceCell({ currency: 'EUR' })).toBeNull()
})
it('leaves out a symbol the row does not have', async () => {
const { priceCell } = await routeWith()
expect(priceCell({ price: 5 }).text).toBe('5.00')
})
})
})