Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 47 additions & 41 deletions packages/core/src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,17 +211,17 @@ export class Response {

const propsToMerge = pageResponse.mergeProps || []
const propsToDeepMerge = pageResponse.deepMergeProps || []
const mergeStrategies = pageResponse.mergeStrategies || []
const matchPropsOn = pageResponse.matchPropsOn || []

propsToMerge.forEach((prop) => {
const incomingProp = pageResponse.props[prop]

if (Array.isArray(incomingProp)) {
pageResponse.props[prop] = mergeArrayWithStrategy(
pageResponse.props[prop] = this.mergeOrMatchItems(
(currentPage.get().props[prop] || []) as any[],
incomingProp,
prop,
mergeStrategies
matchPropsOn,
)
} else if (typeof incomingProp === 'object' && incomingProp !== null) {
pageResponse.props[prop] = {
Expand All @@ -235,10 +235,10 @@ export class Response {
const incomingProp = pageResponse.props[prop]
const currentProp = currentPage.get().props[prop]

// Deep merge function to handle nested objects and arrays
// Function to recursively merge objects and arrays
const deepMerge = (target: any, source: any, currentKey: string) => {
if (Array.isArray(source)) {
return mergeArrayWithStrategy(target, source, currentKey, mergeStrategies)
return this.mergeOrMatchItems(target, source, currentKey, matchPropsOn)
}

if (typeof source === 'object' && source !== null) {
Expand All @@ -252,69 +252,75 @@ export class Response {
)
}

// If the source is neither an array nor an object, return it directly
// f the source is neither an array nor an object, simply return the it
return source
}

// Assign the deeply merged result back to props.
// Apply the deep merge and update the page response
pageResponse.props[prop] = deepMerge(currentProp, incomingProp, prop)
})

pageResponse.props = { ...currentPage.get().props, ...pageResponse.props }
}

protected async setRememberedState(pageResponse: Page): Promise<void> {
const rememberedState = await history.getState<Page['rememberedState']>(history.rememberedState, {})

if (
this.requestParams.all().preserveState &&
rememberedState &&
pageResponse.component === currentPage.get().component
) {
pageResponse.rememberedState = rememberedState
}
}
protected mergeOrMatchItems(target: any[], source: any[], currentKey: string, matchPropsOn: string[]) {
// Determine if there's a specific key to match items.
// E.g.: matchPropsOn = ['posts.data.id'] and currentKey = 'posts.data' will match.
const matchOn = matchPropsOn.find((key) => {
const path = key.split('.').slice(0, -1).join('.')
return path === currentKey
})

protected getScopedErrors(errors: Errors & ErrorBag): Errors {
if (!this.requestParams.all().errorBag) {
return errors
if (!matchOn) {
// No key found to match on, just concatenate the arrays
return [...(Array.isArray(target) ? target : []), ...source]
}

return errors[this.requestParams.all().errorBag || ''] || {}
}
}

function mergeArrayWithStrategy(target: any[], source: any[], currentKey: string, mergeStrategies: string[]) {
// Find the mergeStrategy that matches the currentKey
// For example: posts.data.id matches posts.data
const mergeStrategy = mergeStrategies.find((strategy) => {
const path = strategy.split('.').slice(0, -1).join('.')
return path === currentKey
})

if (mergeStrategy) {
const uniqueProperty = mergeStrategy.split('.').pop() || ''
// Extract the unique property name to match items (e.g., 'id' from 'posts.data.id').
const uniqueProperty = matchOn.split('.').pop() || ''
const targetArray = Array.isArray(target) ? target : []
const map = new Map<any, any>()

targetArray.forEach(item => {
// Populate the map with items from the target array, using the unique property as the key.
// If an item doesn't have the unique property or isn't an object, a unique Symbol is used as the key.
targetArray.forEach((item) => {
if (item && typeof item === 'object' && uniqueProperty in item) {
map.set(item[uniqueProperty], item)
} else {
map.set(Symbol(), item)
}
})

source.forEach(item => {

// Iterate through the source array. If an item's unique property matches an existing key in the map,
// update the item. Otherwise, add the new item to the map.
source.forEach((item) => {
if (item && typeof item === 'object' && uniqueProperty in item) {
map.set(item[uniqueProperty], item)
} else {
map.set(Symbol(), item)
}
})

return Array.from(map.values())
}
// No mergeStrategy: default to concatenation
return [...(Array.isArray(target) ? target : []), ...source]

protected async setRememberedState(pageResponse: Page): Promise<void> {
const rememberedState = await history.getState<Page['rememberedState']>(history.rememberedState, {})

if (
this.requestParams.all().preserveState &&
rememberedState &&
pageResponse.component === currentPage.get().component
) {
pageResponse.rememberedState = rememberedState
}
}

protected getScopedErrors(errors: Errors & ErrorBag): Errors {
if (!this.requestParams.all().errorBag) {
return errors
}

return errors[this.requestParams.all().errorBag || ''] || {}
}
}
2 changes: 1 addition & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface Page<SharedProps extends PageProps = PageProps> {
deferredProps?: Record<string, VisitOptions['only']>
mergeProps?: string[]
deepMergeProps?: string[]
mergeStrategies?: string[]
matchPropsOn?: string[]

/** @internal */
rememberedState: Record<string, unknown>
Expand Down
2 changes: 1 addition & 1 deletion packages/vue3/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function usePage<SharedProps extends PageProps>(): Page<SharedProps> {
deferredProps: computed(() => page.value?.deferredProps),
mergeProps: computed(() => page.value?.mergeProps),
deepMergeProps: computed(() => page.value?.deepMergeProps),
mergeStrategies: computed(() => page.value?.mergeStrategies),
matchPropsOn: computed(() => page.value?.matchPropsOn),
rememberedState: computed(() => page.value?.rememberedState),
encryptHistory: computed(() => page.value?.encryptHistory),
})
Expand Down
6 changes: 3 additions & 3 deletions tests/app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ app.get('/deep-merge-props', (req, res) => {
})
})

app.get('/merge-strategies', (req, res) => {
app.get('/match-props-on-key', (req, res) => {
const labels = ['first', 'second', 'third', 'fourth', 'fifth']

const perPage = 5
Expand All @@ -358,7 +358,7 @@ app.get('/merge-strategies', (req, res) => {
}))

inertia.render(req, res, {
component: 'MergeStrategies',
component: 'MatchPropsOnKey',
props: {
bar: new Array(perPage).fill(1),
baz: new Array(perPage).fill(1),
Expand All @@ -377,7 +377,7 @@ app.get('/merge-strategies', (req, res) => {
? {}
: {
deepMergeProps: ['foo', 'baz'],
mergeStrategies: ['foo.data.id', 'foo.companies.otherId', 'foo.teams.uuid'],
matchPropsOn: ['foo.data.id', 'foo.companies.otherId', 'foo.teams.uuid'],
}),
})
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'

test('can merge props with custom strategies', async ({ page }) => {
await page.goto('/merge-strategies')
test('can match props by a key', async ({ page }) => {
await page.goto('/match-props-on-key')

await expect(page.getByText('bar count is 5')).toBeVisible()
await expect(page.getByText('baz count is 5')).toBeVisible()
Expand Down