Skip to content

Commit 8f89690

Browse files
TkDodoValeraSfranky47
authored
feat: multi-parsers (#1134)
Co-authored-by: Valerii Sidorenko <[email protected]> Co-authored-by: François Best <[email protected]>
1 parent ccedc1b commit 8f89690

40 files changed

Lines changed: 988 additions & 94 deletions

packages/docs/content/docs/parsers/built-in.mdx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
DateISOParserDemo,
1616
DatetimeISOParserDemo,
1717
DateTimestampParserDemo,
18-
JsonParserDemo
18+
JsonParserDemo,
19+
NativeArrayParserDemo
1920
} from '@/content/docs/parsers/demos'
2021

2122
Search params are strings by default, but chances are your state is more complex than that.
@@ -322,6 +323,39 @@ parseAsJson(userSchema.validateSync)
322323
return `null{:ts}` for invalid data. Only **synchronous** validation is supported.
323324
</Callout>
324325

326+
## Native Arrays
327+
328+
<FeatureSupportMatrix introducedInVersion='2.7.0' />
329+
330+
If you want to use the native URL format for arrays, repeating the same key multiple times like:
331+
332+
import { Querystring } from '@/src/components/querystring'
333+
334+
<Querystring path="/products" value='?tag=books&tag=tech&tag=design' />
335+
336+
you can now use `MultiParsers{:ts}` like `parseAsNativeArrayOf{:ts}` to read and write those values in a fully type-safe way.
337+
338+
```tsx
339+
import { useQueryState, parseAsNativeArrayOf, parseAsInteger } from 'nuqs'
340+
341+
const [projectIds, setProjectIds] = useQueryState(
342+
'project',
343+
parseAsNativeArrayOf(parseAsInteger)
344+
)
345+
346+
// ?project=123&project=456 → [123, 456]
347+
```
348+
349+
<Suspense fallback={<DemoFallback />}>
350+
<NativeArrayParserDemo />
351+
</Suspense>
352+
353+
<Callout title="Note: empty array default">
354+
`parseAsNativeArrayOf{:ts}` has a built-in default value of empty array (`.withDefault([]){:ts}`) so that you don't have to handle `null{:ts}` cases.
355+
356+
Calls to `.withDefault(){:ts}` can be chained, so you can use it to set a custom default.
357+
</Callout>
358+
325359
## Using parsers server-side
326360

327361
For shared code that may be imported in the Next.js app router, you should import

packages/docs/content/docs/parsers/demos.tsx

Lines changed: 250 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ import {
1515
} from '@/src/components/ui/pagination'
1616
import { Slider } from '@/src/components/ui/slider'
1717
import { cn } from '@/src/lib/utils'
18-
import { ChevronDown, ChevronUp, Minus, Star } from 'lucide-react'
1918
import {
20-
ParserBuilder,
19+
ChevronDown,
20+
ChevronUp,
21+
Dices,
22+
Minus,
23+
Star,
24+
Trash2
25+
} from 'lucide-react'
26+
import {
27+
createMultiParser,
2128
createParser,
2229
parseAsBoolean,
2330
parseAsFloat,
@@ -27,8 +34,11 @@ import {
2734
parseAsIsoDate,
2835
parseAsIsoDateTime,
2936
parseAsJson,
37+
parseAsNativeArrayOf,
3038
parseAsStringLiteral,
3139
parseAsTimestamp,
40+
ParserBuilder,
41+
SingleParser,
3242
useQueryState
3343
} from 'nuqs'
3444
import React from 'react'
@@ -465,6 +475,244 @@ export function CustomParserDemo() {
465475
)
466476
}
467477

478+
export function NativeArrayParserDemo() {
479+
const [value, setValue] = useQueryState(
480+
'nativeArray',
481+
parseAsNativeArrayOf(parseAsInteger)
482+
)
483+
return (
484+
<DemoContainer demoKey="nativeArray">
485+
<Button
486+
onClick={() =>
487+
setValue(prev => prev.concat(Math.floor(Math.random() * 500) + 1))
488+
}
489+
>
490+
<Dices size={18} className="mr-2 inline-block" role="presentation" />
491+
Add random number
492+
</Button>
493+
<Button
494+
onClick={() => setValue(prev => prev.slice(0, -1))}
495+
disabled={value.length === 0}
496+
>
497+
<Trash2 size={16} className="mr-2 inline-block" role="presentation" />
498+
Remove last number
499+
</Button>
500+
<Button
501+
variant="secondary"
502+
onClick={() => setValue([])}
503+
className="ml-auto"
504+
>
505+
Clear
506+
</Button>
507+
<CodeBlock
508+
lang="json"
509+
code={JSON.stringify(value)}
510+
allowCopy={false}
511+
className="my-0 w-full"
512+
/>
513+
</DemoContainer>
514+
)
515+
}
516+
517+
export function CustomMultiParserDemo() {
518+
const parseAsFromTo = createParser({
519+
parse: value => {
520+
const [min = null, max = null] = value
521+
.split('~')
522+
.map(parseAsInteger.parse)
523+
if (min === null) return null
524+
if (max === null) return { eq: min }
525+
return { gte: min, lte: max }
526+
},
527+
serialize: value => {
528+
return value.eq !== undefined
529+
? String(value.eq)
530+
: `${value.gte}~${value.lte}`
531+
}
532+
})
533+
534+
const parseAsKeyValue = createParser({
535+
parse: value => {
536+
const [key, val] = value.split(':')
537+
if (!key || !val) return null
538+
return { key, value: val }
539+
},
540+
serialize: value => {
541+
return `${value.key}:${value.value}`
542+
}
543+
})
544+
545+
const parseAsFilters = <TItem extends {}>(
546+
itemParser: SingleParser<TItem>
547+
) => {
548+
return createMultiParser({
549+
parse: values => {
550+
const keyValue = values
551+
.map(parseAsKeyValue.parse)
552+
.filter(v => v !== null)
553+
554+
const result = Object.fromEntries(
555+
keyValue.flatMap(({ key, value }) => {
556+
const parsedValue: TItem | null = itemParser.parse(value)
557+
return parsedValue === null ? [] : [[key, parsedValue]]
558+
})
559+
)
560+
561+
return Object.keys(result).length === 0 ? null : result
562+
},
563+
serialize: values => {
564+
return Object.entries(values)
565+
.map(([key, value]) => {
566+
if (!itemParser.serialize) return null
567+
return parseAsKeyValue.serialize({
568+
key,
569+
value: itemParser.serialize(value)
570+
})
571+
})
572+
.filter(v => v !== null)
573+
}
574+
})
575+
}
576+
577+
const [filters, setFilters] = useQueryState(
578+
'filters',
579+
parseAsFilters(parseAsFromTo).withDefault({})
580+
)
581+
582+
return (
583+
<DemoContainer demoKey="filters">
584+
<div>
585+
<label className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
586+
Rating:
587+
</label>
588+
<input
589+
type="number"
590+
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
591+
value={filters.rating?.eq ?? ''}
592+
min={0}
593+
max={5}
594+
onChange={e => {
595+
setFilters(prev => ({
596+
...prev,
597+
rating: { eq: e.target.value === '' ? 0 : e.target.valueAsNumber }
598+
}))
599+
}}
600+
autoComplete="off"
601+
/>
602+
</div>
603+
<div>
604+
<label className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
605+
Price From:
606+
</label>
607+
<input
608+
type="number"
609+
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
610+
value={filters.price?.gte ?? 0}
611+
step={10}
612+
max={1000}
613+
onChange={e => {
614+
setFilters(prev => ({
615+
...prev,
616+
price: {
617+
lte: prev.price?.lte ?? 0,
618+
gte: e.target.value === '' ? 0 : e.target.valueAsNumber
619+
}
620+
}))
621+
}}
622+
autoComplete="off"
623+
/>
624+
</div>
625+
<div>
626+
<label className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
627+
Price To:
628+
</label>
629+
<input
630+
type="number"
631+
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
632+
value={filters.price?.lte ?? 0}
633+
step={10}
634+
max={1000}
635+
onChange={e => {
636+
setFilters(prev => ({
637+
...prev,
638+
price: {
639+
gte: prev.price?.gte ?? 0,
640+
lte: e.target.value === '' ? 0 : e.target.valueAsNumber
641+
}
642+
}))
643+
}}
644+
autoComplete="off"
645+
/>
646+
</div>
647+
<Button
648+
variant="secondary"
649+
onClick={() => setFilters(null)}
650+
className="mt-auto ml-auto"
651+
>
652+
Clear
653+
</Button>
654+
</DemoContainer>
655+
)
656+
657+
return (
658+
<DemoContainer demoKey="filters">
659+
{Object.entries(filters).map(([key, value]) => {
660+
if (value.eq !== undefined) {
661+
return (
662+
<div key={key}>
663+
<label className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
664+
{key}:{' '}
665+
</label>
666+
<input
667+
key={key}
668+
type="number"
669+
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
670+
value={value.eq}
671+
onChange={e => {
672+
setFilters(prev => ({
673+
...prev,
674+
[key]: { eq: e.target.valueAsNumber }
675+
}))
676+
}}
677+
placeholder="What's your favourite number?"
678+
autoComplete="off"
679+
/>
680+
</div>
681+
)
682+
}
683+
return (
684+
<div key={key}>
685+
<label className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
686+
{key}:{' '}
687+
</label>
688+
<input
689+
key={key}
690+
type="number"
691+
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
692+
value={value.eq}
693+
onChange={e => {
694+
setFilters(prev => ({
695+
...prev,
696+
[key]: { eq: e.target.valueAsNumber }
697+
}))
698+
}}
699+
placeholder="What's your favourite number?"
700+
autoComplete="off"
701+
/>
702+
</div>
703+
)
704+
})}
705+
<Button
706+
variant="secondary"
707+
onClick={() => setFilters(null)}
708+
className="ml-auto"
709+
>
710+
Clear
711+
</Button>
712+
</DemoContainer>
713+
)
714+
}
715+
468716
type StarButtonProps = Omit<React.ComponentProps<typeof Button>, 'value'> & {
469717
index: Rating
470718
value: Rating | null

0 commit comments

Comments
 (0)