Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add parseAsPageIndex parser #791

Merged
merged 14 commits into from
Feb 9, 2025
20 changes: 18 additions & 2 deletions packages/docs/content/docs/parsers/built-in.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
StringParserDemo,
FloatParserDemo,
HexParserDemo,
IndexParserDemo,
BooleanParserDemo,
StringLiteralParserDemo,
DateISOParserDemo,
Expand Down Expand Up @@ -105,6 +106,20 @@ useQueryState('hex', parseAsHex.withDefault(0x00))
Check out the [Hex Colors](/playground/hex-colors) playground for a demo.
</Callout>

### Index

Same as integer, but adds a `+1` offset to the query value. Useful for pagination indexes.

```ts
import { parseAsIndex } from 'nuqs'

useQueryState('page', parseAsIndex.withDefault(0))
```

<Suspense fallback={<DemoFallback />}>
<IndexParserDemo />
</Suspense>

## Boolean

```ts
Expand Down Expand Up @@ -203,8 +218,9 @@ import { parseAsIsoDate } from 'nuqs'
</Suspense>

<Callout>
The Date is parsed without the time zone offset, making it at 00:00:00 UTC.<br/>
<span className='block mt-1.5'>_Support: introduced in version 2.1.0._</span>
The Date is parsed without the time zone offset, making it at 00:00:00 UTC.
<br />
<span className="mt-1.5 block">_Support: introduced in version 2.1.0._</span>
</Callout>

### Timestamp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from '@/src/components/ui/select'
import { Separator } from '@/src/components/ui/separator'
import {
createParser,
parseAsIndex,
parseAsInteger,
parseAsString,
useQueryState
Expand All @@ -29,19 +29,6 @@ import { useDeferredValue } from 'react'

const NUM_PAGES = 5

// The page index parser is zero-indexed internally,
// but one-indexed when rendered in the URL,
// to align with your UI and what users might expect.
const pageIndexParser = createParser({
parse: query => {
const page = parseAsInteger.parse(query)
return page === null ? null : page - 1
},
serialize: value => {
return parseAsInteger.serialize(value + 1)
}
})

export function TanStackTablePagination() {
const [pageIndexUrlKey, setPageIndexUrlKey] = useQueryState(
'pageIndexUrlKey',
Expand All @@ -53,35 +40,22 @@ export function TanStackTablePagination() {
)
const [page, setPage] = useQueryState(
pageIndexUrlKey,
pageIndexParser.withDefault(0)
parseAsIndex.withDefault(0)
)
const [pageSize, setPageSize] = useQueryState(
pageSizeUrlKey,
parseAsInteger.withDefault(10)
)

const parserCode = useDeferredValue(`import {
createParser,
parseAsIndex,
parseAsInteger,
parseAsString,
useQueryStates
} from 'nuqs'

// The page index parser is zero-indexed internally,
// but one-indexed when rendered in the URL,
// to align with your UI and what users might expect.
const pageIndexParser = createParser({
parse: query => {
const page = parseAsInteger.parse(query)
return page === null ? null : page - 1
},
serialize: value => {
return parseAsInteger.serialize(value + 1)
}
})

const paginationParsers = {
pageIndex: pageIndexParser.withDefault(0),
pageIndex: parseAsIndex.withDefault(0),
pageSize: parseAsInteger.withDefault(10)
}
const paginationUrlKeys = {
Expand Down
29 changes: 29 additions & 0 deletions packages/docs/content/docs/parsers/demos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
parseAsBoolean,
parseAsFloat,
parseAsHex,
parseAsIndex,
parseAsInteger,
parseAsIsoDate,
parseAsIsoDateTime,
Expand Down Expand Up @@ -171,6 +172,34 @@ export function HexParserDemo() {
)
}

export function IndexParserDemo() {
const [value, setValue] = useQueryState('page', parseAsIndex)
return (
<DemoContainer demoKey="page">
<input
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Add a minimum of zero for the input, as the parser now checks for positive-only values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minimum of zero makes sense for page index, but as a general index, a value -1 or below could be utilised to iterate backwards on an array. Something like .withOptions({ min: 0}) would make sense. Was that what you had in mind?

Copy link
Member

@franky47 franky47 Feb 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I thought about going negative to do python-like backwards indexing (using .at), but it would mean:

  • ?page=0 maps to the last element (index -1)
  • ?page=-1 maps to the second to last element (index -2)

This seems even more confusing.

I'll remove the positive check and we can enforce runtime values with validation once #446 (or a variant) lands.

Note: TanStack Table doesn't seem to do a bounds check on negative indices: https://table.sadmn.com/?flags=advancedTable&page=-1

type="number"
className="flex h-10 flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={value ?? ''} // Handle empty input
onChange={e => {
if (e.target.value === '') {
setValue(null)
} else {
setValue(e.target.valueAsNumber)
}
}}
placeholder="What page are you on?"
/>
<Button
variant="secondary"
onClick={() => setValue(null)}
className="ml-auto"
>
Clear
</Button>
</DemoContainer>
)
}

export function BooleanParserDemo() {
const [value, setValue] = useQueryState(
'bool',
Expand Down
5 changes: 4 additions & 1 deletion packages/e2e/next/cypress/e2e/cache.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@

describe('cache', () => {
it('works in app router', () => {
cy.visit('/app/cache?str=foo&num=42&bool=true&multi=foo&multi=bar')
cy.visit('/app/cache?str=foo&num=42&idx=1&bool=true&multi=foo&multi=bar')
cy.get('#parse-str').should('have.text', 'foo')
cy.get('#parse-num').should('have.text', '42')
cy.get('#parse-idx').should('have.text', '0')
cy.get('#parse-bool').should('have.text', 'true')
cy.get('#parse-def').should('have.text', 'default')
cy.get('#parse-nope').should('have.text', 'null')
cy.get('#all-str').should('have.text', 'foo')
cy.get('#all-num').should('have.text', '42')
cy.get('#all-idx').should('have.text', '0')
cy.get('#all-bool').should('have.text', 'true')
cy.get('#all-def').should('have.text', 'default')
cy.get('#all-nope').should('have.text', 'null')
cy.get('#get-str').should('have.text', 'foo')
cy.get('#get-num').should('have.text', '42')
cy.get('#get-idx').should('have.text', '0')
cy.get('#get-bool').should('have.text', 'true')
cy.get('#get-def').should('have.text', 'default')
cy.get('#get-nope').should('have.text', 'null')
Expand Down
21 changes: 21 additions & 0 deletions packages/e2e/next/cypress/e2e/useQueryState.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ function runTest(pathname) {
cy.get('#bool_value').should('be.empty')
}

// Index
{
cy.get('#index_value').should('be.empty')
cy.get('#index_increment').click()
cy.location('search').should('eq', '?index=2')
cy.get('#index_value').should('have.text', '1')
cy.get('#index_increment').click()
cy.location('search').should('eq', '?index=3')
cy.get('#index_value').should('have.text', '2')
cy.get('#index_decrement').click()
cy.location('search').should('eq', '?index=2')
cy.get('#index_value').should('have.text', '1')
cy.get('#index_decrement').click()
cy.location('search').should('eq', '?index=1')
cy.get('#index_value').should('have.text', '0')
cy.get('#index_decrement').click()
cy.get('#index_clear').click()
cy.location('search').should('be.empty')
cy.get('#index_value').should('be.empty')
}

// todo: Add tests for:
// Timestamp
// ISO DateTime
Expand Down
27 changes: 19 additions & 8 deletions packages/e2e/next/cypress/e2e/useQueryStates.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ function runTest() {
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
cy.get('#json').should(
'have.text',
'{"string":null,"int":null,"float":null,"bool":null}'
'{"string":null,"int":null,"float":null,"index":null,"bool":null}'
)
cy.get('#string').should('be.empty')
cy.get('#int').should('be.empty')
cy.get('#float').should('be.empty')
cy.get('#index').should('be.empty')
cy.get('#bool').should('be.empty')
cy.location('search').should('be.empty')

Expand All @@ -17,60 +18,70 @@ function runTest() {
cy.get('#string').should('have.text', 'Hello')
cy.get('#json').should(
'have.text',
'{"string":"Hello","int":null,"float":null,"bool":null}'
'{"string":"Hello","int":null,"float":null,"index":null,"bool":null}'
)

cy.contains('Set int').click()
cy.location('search').should('include', 'int=42')
cy.get('#int').should('have.text', '42')
cy.get('#json').should(
'have.text',
'{"string":"Hello","int":42,"float":null,"bool":null}'
'{"string":"Hello","int":42,"float":null,"index":null,"bool":null}'
)

cy.contains('Set float').click()
cy.location('search').should('include', 'float=3.14159')
cy.get('#float').should('have.text', '3.14159')
cy.get('#json').should(
'have.text',
'{"string":"Hello","int":42,"float":3.14159,"bool":null}'
'{"string":"Hello","int":42,"float":3.14159,"index":null,"bool":null}'
)

cy.contains('Set index').click()
cy.location('search').should('include', 'index=9')
cy.get('#index').should('have.text', '8')
cy.get('#json').should(
'have.text',
'{"string":"Hello","int":42,"float":3.14159,"index":8,"bool":null}'
)

cy.contains('Toggle bool').click()
cy.location('search').should('include', 'bool=true')
cy.get('#bool').should('have.text', 'true')
cy.get('#json').should(
'have.text',
'{"string":"Hello","int":42,"float":3.14159,"bool":true}'
'{"string":"Hello","int":42,"float":3.14159,"index":8,"bool":true}'
)
cy.contains('Toggle bool').click()
cy.location('search').should('include', 'bool=false')
cy.get('#bool').should('have.text', 'false')
cy.get('#json').should(
'have.text',
'{"string":"Hello","int":42,"float":3.14159,"bool":false}'
'{"string":"Hello","int":42,"float":3.14159,"index":8,"bool":false}'
)

cy.get('#clear-string').click()
cy.location('search').should('not.include', 'string=Hello')
cy.get('#string').should('be.empty')
cy.get('#json').should(
'have.text',
'{"string":null,"int":42,"float":3.14159,"bool":false}'
'{"string":null,"int":42,"float":3.14159,"index":8,"bool":false}'
)

cy.get('#clear').click()
cy.location('search').should('not.include', 'string')
cy.location('search').should('not.include', 'int')
cy.location('search').should('not.include', 'float')
cy.location('search').should('not.include', 'index')
cy.location('search').should('not.include', 'bool')
cy.get('#json').should(
'have.text',
'{"string":null,"int":null,"float":null,"bool":null}'
'{"string":null,"int":null,"float":null,"index":null,"bool":null}'
)
cy.get('#string').should('be.empty')
cy.get('#int').should('be.empty')
cy.get('#float').should('be.empty')
cy.get('#index').should('be.empty')
cy.get('#bool').should('be.empty')
cy.location('search').should('be.empty')
}
Expand Down
3 changes: 2 additions & 1 deletion packages/e2e/next/src/app/app/cache/all.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { cache } from './searchParams'

export function All() {
const { bool, num, str, def, nope } = cache.all()
const { bool, num, str, def, nope, idx } = cache.all()
return (
<>
<h2>From all:</h2>
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="all-str">{str}</span>
<span id="all-num">{num}</span>
<span id="all-idx">{String(idx)}</span>
<span id="all-bool">{String(bool)}</span>
<span id="all-def">{def}</span>
<span id="all-nope">{String(nope)}</span>
Expand Down
2 changes: 2 additions & 0 deletions packages/e2e/next/src/app/app/cache/get.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cache } from './searchParams'
export function Get() {
const bool = cache.get('bool')
const num = cache.get('num')
const idx = cache.get('idx')
const str = cache.get('str')
const def = cache.get('def')
const nope = cache.get('nope')
Expand All @@ -12,6 +13,7 @@ export function Get() {
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="get-str">{str}</span>
<span id="get-num">{num}</span>
<span id="get-idx">{String(idx)}</span>
<span id="get-bool">{String(bool)}</span>
<span id="get-def">{def}</span>
<span id="get-nope">{String(nope)}</span>
Expand Down
3 changes: 2 additions & 1 deletion packages/e2e/next/src/app/app/cache/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ type Props = {
}

export default async function Page({ searchParams }: Props) {
const { str, bool, num, def, nope } = await cache.parse(searchParams)
const { str, bool, num, def, nope, idx } = await cache.parse(searchParams)
return (
<>
<h1>Root page</h1>
<h2>From parse:</h2>
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="parse-str">{str}</span>
<span id="parse-num">{num}</span>
<span id="parse-idx">{String(idx)}</span>
<span id="parse-bool">{String(bool)}</span>
<span id="parse-def">{def}</span>
<span id="parse-nope">{String(nope)}</span>
Expand Down
2 changes: 2 additions & 0 deletions packages/e2e/next/src/app/app/cache/searchParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import {
createSearchParamsCache,
parseAsBoolean,
parseAsInteger,
parseAsIndex,
parseAsString
} from 'nuqs/server'

export const parsers = {
str: parseAsString,
num: parseAsInteger,
idx: parseAsIndex,
bool: parseAsBoolean,
def: parseAsString.withDefault('default'),
nope: parseAsString
Expand Down
3 changes: 2 additions & 1 deletion packages/e2e/next/src/app/app/cache/set.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useQueryStates } from 'nuqs'
import { parsers } from './searchParams'

export function Set() {
const [{ bool, num, str, def, nope }, set] = useQueryStates(parsers, {
const [{ bool, num, str, def, nope, idx }, set] = useQueryStates(parsers, {
shallow: false
})
return (
Expand All @@ -16,6 +16,7 @@ export function Set() {
<p style={{ display: 'flex', gap: '1rem' }}>
<span id="set-str">{str}</span>
<span id="set-num">{num}</span>
<span id="set-idx">{String(idx)}</span>
<span id="set-bool">{String(bool)}</span>
<span id="set-def">{def}</span>
<span id="set-nope">{String(nope)}</span>
Expand Down
Loading