Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion packages/react-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc",
"bundle": "rm -rf dist && tsc -p tsconfig.bundle.json"
"bundle": "rm -rf dist && tsc -p tsconfig.bundle.json",
"test": "vitest run"
},
"keywords": [],
"author": "",
Expand Down
23 changes: 18 additions & 5 deletions packages/react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type {
} from '@genseki/react'
import { createRestClient, type CreateRestClientConfig } from '@genseki/rest'

import { isEmptyObject, normalizeObject, sortObjectDeep } from './utils'

type QueryMethod = 'GET'
type MutationMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE'
type Method = QueryMethod | MutationMethod
Expand Down Expand Up @@ -210,12 +212,23 @@ export type QueryClient<TApiRouter extends FlatApiRouter> =
: never

export function queryKey(method: string, path: string | number | symbol, payload?: any) {
const payloadKey = {
pathParams: payload?.pathParams ?? {},
query: payload?.query ?? {},
headers: payload?.headers ?? {},
const { header, ...rest } = payload
const normalizedPayload = normalizeObject(rest)
const normalizedHeaders = header && !isEmptyObject(header) ? sortObjectDeep(header) : undefined

if (normalizedHeaders && normalizedPayload) {
return [method, path, normalizedPayload, normalizedHeaders] as const
}

if (normalizedHeaders) {
return [method, path, normalizedHeaders] as const
}
return [method, path, payloadKey] as const

if (normalizedPayload) {
return [method, path, normalizedPayload] as const
}

return [method, path] as const
}

export function createQueryClient<TApp extends GensekiAppCompiled>(
Expand Down
154 changes: 154 additions & 0 deletions packages/react-query/src/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest'

import { isEmptyObject, normalizeObject, sortObjectDeep } from './utils'

describe('isEmptyObject', () => {
it('should return true for empty object', () => {
expect(isEmptyObject({})).toBe(true)
})

it('should return false for object with properties', () => {
expect(isEmptyObject({ id: 1 })).toBe(false)
})

it('should return false for null', () => {
expect(isEmptyObject(null)).toBe(false)
})

it('should return false for undefined', () => {
expect(isEmptyObject(undefined)).toBe(false)
})

it('should return false for arrays', () => {
expect(isEmptyObject([])).toBe(false)
expect(isEmptyObject([1, 2, 3])).toBe(false)
})

it('should return false for primitives', () => {
expect(isEmptyObject('string')).toBe(false)
expect(isEmptyObject(42)).toBe(false)
expect(isEmptyObject(true)).toBe(false)
})
})

describe('sortObjectDeep', () => {
it('should sort object keys alphabetically', () => {
const input = { z: 1, a: 2, m: 3 }
const expected = { a: 2, m: 3, z: 1 }
expect(sortObjectDeep(input)).toEqual(expected)
})

it('should sort nested objects recursively', () => {
const input = {
z: { y: 1, x: 2 },
a: { c: 3, b: 4 },
}
const expected = {
a: { b: 4, c: 3 },
z: { x: 2, y: 1 },
}
expect(sortObjectDeep(input)).toEqual(expected)
})

it('should handle arrays without sorting', () => {
const input = { items: [3, 1, 2] }
const expected = { items: [3, 1, 2] }
expect(sortObjectDeep(input)).toEqual(expected)
})

it('should handle primitives', () => {
expect(sortObjectDeep(null)).toBe(null)
expect(sortObjectDeep(undefined)).toBe(undefined)
expect(sortObjectDeep(42)).toBe(42)
expect(sortObjectDeep('string')).toBe('string')
})

it('should handle deeply nested mixed structures', () => {
const input = {
z: { nested: { b: 1, a: 2 } },
a: [1, 2, 3],
m: 'string',
}
const expected = {
a: [1, 2, 3],
m: 'string',
z: { nested: { a: 2, b: 1 } },
}
expect(sortObjectDeep(input)).toEqual(expected)
})

it('should maintain object references for primitives', () => {
const obj = { value: 42 }
expect(sortObjectDeep(obj)).toEqual({ value: 42 })
})
})

describe('normalizeObject', () => {
it('should return undefined for falsy inputs', () => {
expect(normalizeObject()).toBe(undefined)
expect(normalizeObject(null)).toBe(undefined)
expect(normalizeObject(undefined)).toBe(undefined)
expect(normalizeObject(false)).toBe(undefined)
})

it('should return undefined when all fields are empty', () => {
const payload = {
query: {},
pathParams: {},
}
expect(normalizeObject(payload)).toBe(undefined)
})

it('should filter and sort non-empty objects', () => {
const payload = {
query: { z: 1, a: 2 },
pathParams: { id: '123' },
}
const expected = {
pathParams: { id: '123' },
query: { a: 2, z: 1 },
}
expect(normalizeObject(payload)).toEqual(expected)
})

it('should exclude empty objects while keeping non-empty ones', () => {
const payload = {
query: { name: 'John' },
pathParams: {},
}
const expected = {
query: { name: 'John' },
}
expect(normalizeObject(payload)).toEqual(expected)
})

it('should handle nested objects in query and pathParams', () => {
const payload = {
query: {
filter: { z: 'last', a: 'first' },
sort: 'name',
},
pathParams: {
nested: { b: 2, a: 1 },
},
}
const expected = {
pathParams: {
nested: { a: 1, b: 2 },
},
query: {
filter: { a: 'first', z: 'last' },
sort: 'name',
},
}
expect(normalizeObject(payload)).toEqual(expected)
})

it('should handle null and undefined values in fields', () => {
const payload = {
query: null,
pathParams: undefined,
}
expect(normalizeObject(payload)).toBe(undefined)
})
})
31 changes: 31 additions & 0 deletions packages/react-query/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function isEmptyObject(obj: any): boolean {
return (
obj != null && typeof obj === 'object' && !Array.isArray(obj) && Object.keys(obj).length === 0
)
}

export function sortObjectDeep(obj: any): any {
if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) {
return obj
}

return Object.keys(obj)
.sort()
.reduce((result, key) => {
result[key] = sortObjectDeep(obj[key])
return result
}, {} as any)
}

export function normalizeObject(payload?: any): any {
if (!payload) return undefined

const filtered = Object.entries(payload).reduce((acc, [key, value]) => {
if (value != null && !isEmptyObject(value)) {
acc[key] = sortObjectDeep(value)
}
return acc
}, {} as any)

return Object.keys(filtered).length > 0 ? filtered : undefined
}
5 changes: 2 additions & 3 deletions packages/react/src/react/views/collections/list/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,9 @@ function _CollectionListProvider<T extends BaseData>(props: CollectionListProvid
const queryClient = useQueryClient()
const query = useCollectionListQuery({ slug: context.slug })

const invalidateList = async (page?: number) => {
const additionalKeys = page ? [{ query: { page } }] : []
const invalidateList = async () => {
await queryClient.invalidateQueries({
queryKey: ['GET', `/${context.slug}`, ...additionalKeys],
queryKey: query.queryKey,
exact: false,
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export function useCollectionListQuery(
search: args.search ?? search,
sort: sort,
}
const fullQueryKey = ['GET', `/${args.slug}`, { query: queryKey }] as const

const query: UseQueryResult<CollectionListResponse> = useQuery({
queryKey: ['GET', `/${args.slug}`, { query: queryKey }] as const,
queryKey: fullQueryKey,
queryFn: async (context) => {
const [, , payload] = context.queryKey
const params = new URLSearchParams([
Expand Down Expand Up @@ -50,5 +51,5 @@ export function useCollectionListQuery(
placeholderData: keepPreviousData,
})

return query
return { ...query, queryKey: fullQueryKey }
}
Loading