From 107fcc165e8b087fe7175aba848be09316f046f4 Mon Sep 17 00:00:00 2001 From: Tyler <26290074+tylersayshi@users.noreply.github.com> Date: Fri, 25 Oct 2024 23:44:16 -0700 Subject: [PATCH] feat: Add parseAsISODate (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add parseAsISODate * fix: Comments from review * fix: Remove GMT Co-authored-by: François Best <github@francoisbest.com> * fix: Query keys --------- Co-authored-by: Tyler <26290074+thegitduck@users.noreply.github.com> Co-authored-by: Tyler <tyler@secondspectrum.com> Co-authored-by: François Best <github@francoisbest.com> --- .../docs/content/docs/parsers/built-in.mdx | 67 +++++++++++-------- packages/docs/content/docs/parsers/demos.tsx | 27 ++++++-- packages/nuqs/src/parsers.test.ts | 9 +++ packages/nuqs/src/parsers.ts | 19 ++++++ 4 files changed, 90 insertions(+), 32 deletions(-) diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index 3afb76185..9dda246b9 100644 --- a/packages/docs/content/docs/parsers/built-in.mdx +++ b/packages/docs/content/docs/parsers/built-in.mdx @@ -12,6 +12,7 @@ import { BooleanParserDemo, StringLiteralParserDemo, DateISOParserDemo, + DatetimeISOParserDemo, DateTimestampParserDemo, JsonParserDemo } from '@/content/docs/parsers/demos' @@ -29,8 +30,8 @@ This is where **parsers** come in. import { parseAsString } from 'nuqs' ``` -<Suspense fallback={<DemoFallback/>}> - <StringParserDemo/> +<Suspense fallback={<DemoFallback />}> + <StringParserDemo /> </Suspense> <Callout title="Type-safety tip"> @@ -39,6 +40,7 @@ and will accept **any** value. If you're expecting a certain set of string values, like `'foo' | 'bar'{:ts}`, see [Literals](#literals) for ensuring type-runtime safety. + </Callout> If search params are strings by default, what's the point of this _"parser"_ ? @@ -67,11 +69,10 @@ import { parseAsInteger } from 'nuqs' useQueryState('int', parseAsInteger.withDefault(0)) ``` -<Suspense fallback={<DemoFallback/>}> - <IntegerParserDemo/> +<Suspense fallback={<DemoFallback />}> + <IntegerParserDemo /> </Suspense> - ### Floating point Same as integer, but uses `parseFloat` under the hood. @@ -82,11 +83,10 @@ import { parseAsFloat } from 'nuqs' useQueryState('float', parseAsFloat.withDefault(0)) ``` -<Suspense fallback={<DemoFallback/>}> - <FloatParserDemo/> +<Suspense fallback={<DemoFallback />}> + <FloatParserDemo /> </Suspense> - ### Hexadecimal Encodes integers in hexadecimal. @@ -97,12 +97,12 @@ import { parseAsHex } from 'nuqs' useQueryState('hex', parseAsHex.withDefault(0x00)) ``` -<Suspense fallback={<DemoFallback/>}> - <HexParserDemo/> +<Suspense fallback={<DemoFallback />}> + <HexParserDemo /> </Suspense> <Callout title="Going further"> - Check out the [Hex Colors](/playground/hex-colors) playground for a demo. + Check out the [Hex Colors](/playground/hex-colors) playground for a demo. </Callout> ## Boolean @@ -113,8 +113,8 @@ import { parseAsBoolean } from 'nuqs' useQueryState('bool', parseAsBoolean.withDefault(false)) ``` -<Suspense fallback={<DemoFallback/>}> - <BooleanParserDemo/> +<Suspense fallback={<DemoFallback />}> + <BooleanParserDemo /> </Suspense> ## Literals @@ -138,14 +138,13 @@ const sortOrder = ['asc', 'desc'] as const parseAsStringLiteral(sortOrder) // Optional: extract the type from them -type SortOrder = (typeof sortOrder)[number]; // 'asc' | 'desc' +type SortOrder = (typeof sortOrder)[number] // 'asc' | 'desc' ``` -<Suspense fallback={<DemoFallback/>}> - <StringLiteralParserDemo/> +<Suspense fallback={<DemoFallback />}> + <StringLiteralParserDemo /> </Suspense> - ### Numeric literals ```ts /as const/ @@ -174,8 +173,8 @@ parseAsStringEnum<Direction>(Object.values(Direction)) ``` <Callout title="Note"> -The query string value will be the **value** of the enum, not its name -(here: `?direction=UP`). + The query string value will be the **value** of the enum, not its name (here: + `?direction=UP`). </Callout> ## Dates & timestamps @@ -183,14 +182,26 @@ The query string value will be the **value** of the enum, not its name There are two parsers that give you a `Date` object, their difference is on how they encode the value into the query string. -### ISO 8601 +### ISO 8601 Datetime ```ts import { parseAsIsoDateTime } from 'nuqs' ``` <Suspense> - <DateISOParserDemo/> + <DatetimeISOParserDemo /> +</Suspense> + +### ISO 8601 Date + +Note: the Date is parsed without the time zone offset, making it at GMT 00:00:00 UTC. + +```ts +import { parseAsIsoDate } from 'nuqs' +``` + +<Suspense> + <DateISOParserDemo /> </Suspense> ### Timestamp @@ -202,7 +213,7 @@ import { parseAsTimestamp } from 'nuqs' ``` <Suspense> - <DateTimestampParserDemo/> + <DateTimestampParserDemo /> </Suspense> ## Arrays @@ -241,14 +252,14 @@ const schema = z.object({ const [json, setJson] = useQueryState('json', parseAsJson(schema.parse)) setJson({ - pkg: "nuqs", + pkg: 'nuqs', version: 2, - worksWith: ["Next.js", "React", "Remix", "React Router", "and more"], -}); + worksWith: ['Next.js', 'React', 'Remix', 'React Router', 'and more'] +}) ``` <Suspense> - <JsonParserDemo/> + <JsonParserDemo /> </Suspense> Using other validation libraries is possible, as long as they throw an error @@ -264,6 +275,6 @@ import { parseAsString } from 'nuqs/server' ``` <Callout title="Note"> -It used to be available under the alias import `nuqs/parsers`, -which will be dropped in the next major version. + It used to be available under the alias import `nuqs/parsers`, which will be + dropped in the next major version. </Callout> diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index fab14bcd6..2429926d5 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -14,6 +14,7 @@ import { parseAsFloat, parseAsHex, parseAsInteger, + parseAsIsoDate, parseAsIsoDateTime, parseAsJson, parseAsStringLiteral, @@ -246,10 +247,12 @@ export function StringLiteralParserDemo() { export function DateParserDemo({ queryKey, - parser + parser, + type }: { queryKey: string parser: ParserBuilder<Date> + type: 'date' | 'datetime-local' }) { const [value, setValue] = useQueryState(queryKey, parser) return ( @@ -257,7 +260,7 @@ export function DateParserDemo({ <div className="flex w-full flex-col items-stretch gap-2 @md:flex-row"> <div className="flex flex-1 items-center gap-2"> <input - type="datetime-local" + type={type} className="flex h-10 flex-[2] 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 === null ? '' : value.toISOString().slice(0, -8)} onChange={e => { @@ -290,12 +293,28 @@ export function DateParserDemo({ ) } +export function DatetimeISOParserDemo() { + return ( + <DateParserDemo + type="datetime-local" + queryKey="iso" + parser={parseAsIsoDateTime} + /> + ) +} + export function DateISOParserDemo() { - return <DateParserDemo queryKey="iso" parser={parseAsIsoDateTime} /> + return <DateParserDemo type="date" queryKey="date" parser={parseAsIsoDate} /> } export function DateTimestampParserDemo() { - return <DateParserDemo queryKey="ts" parser={parseAsTimestamp} /> + return ( + <DateParserDemo + type="datetime-local" + queryKey="ts" + parser={parseAsTimestamp} + /> + ) } const jsonParserSchema = z.object({ diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index e2fb37ce3..74d9d3d0c 100644 --- a/packages/nuqs/src/parsers.test.ts +++ b/packages/nuqs/src/parsers.test.ts @@ -5,6 +5,7 @@ import { parseAsHex, parseAsInteger, parseAsIsoDateTime, + parseAsIsoDate, parseAsString, parseAsTimestamp } from './parsers' @@ -48,6 +49,14 @@ describe('parsers', () => { ref ) }) + test('parseAsIsoDate', () => { + expect(parseAsIsoDate.parse('')).toBeNull() + expect(parseAsIsoDate.parse('not-a-date')).toBeNull() + const moment = '2020-01-01' + const ref = new Date(moment) + expect(parseAsIsoDate.parse(moment)).toStrictEqual(ref) + expect(parseAsIsoDate.serialize(ref)).toEqual(moment) + }) test('parseAsArrayOf', () => { const parser = parseAsArrayOf(parseAsString) expect(parser.serialize([])).toBe('') diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 22d45cc14..82973a601 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -215,6 +215,25 @@ export const parseAsIsoDateTime = createParser({ serialize: (v: Date) => v.toISOString() }) +/** + * Querystring encoded as an ISO-8601 string (UTC) + * without the time zone offset, and returned as + * a Date object. + * + * The Date is parsed without the time zone offset, + * making it at 00:00:00 UTC. + */ +export const parseAsIsoDate = createParser({ + parse: v => { + const date = new Date(v.slice(0, 10)) + if (Number.isNaN(date.valueOf())) { + return null + } + return date + }, + serialize: (v: Date) => v.toISOString().slice(0, 10) +}) + /** * String-based enums provide better type-safety for known sets of values. * You will need to pass the parseAsStringEnum function a list of your enum values