Openstatus www.openstatus.dev

feat: nuqs (#1001)

* wip:

* ci: apply automated fixes

* chore: custom parser for page index

* fix: key

* chore: mute console

* ci: apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Maximilian Kaske
autofix-ci[bot]
and committed by
GitHub
e4dc94e9 96bbc4b8

+522 -797
+1
apps/web/package.json
··· 66 66 "next-contentlayer": "0.3.4", 67 67 "next-plausible": "3.12.0", 68 68 "next-themes": "0.2.1", 69 + "nuqs": "1.19.1", 69 70 "posthog-js": "1.136.1", 70 71 "posthog-node": "4.0.1", 71 72 "random-word-slugs": "0.1.7",
+27 -38
apps/web/src/app/(content)/blog/page.tsx
··· 15 15 import { Rss } from "lucide-react"; 16 16 import type { Metadata } from "next"; 17 17 import Link from "next/link"; 18 - import { z } from "zod"; 18 + import { 19 + ITEMS_PER_PAGE, 20 + MAX_PAGE_INDEX, 21 + searchParamsCache, 22 + } from "./search-params"; 19 23 20 24 export const metadata: Metadata = { 21 25 ...defaultMetadata, ··· 30 34 }, 31 35 }; 32 36 33 - const searchParamsSchema = z.object({ 34 - page: z 35 - .string() 36 - .optional() 37 - .transform((val) => Number.parseInt(val || "1", 10)), 38 - }); 39 - 40 - const ITEMS_PER_PAGE = 10; 41 - 42 37 export default function Post({ 43 38 searchParams, 44 39 }: { 45 40 searchParams: { [key: string]: string | string[] | undefined }; 46 41 }) { 47 - const search = searchParamsSchema.safeParse(searchParams); 48 - 49 - const page = search.data?.page; 50 - const current = !page ? 1 : page; 51 - const total = Math.ceil(allPosts.length / ITEMS_PER_PAGE); 42 + const { pageIndex } = searchParamsCache.parse(searchParams); 52 43 53 44 const posts = allPosts 54 45 .sort( 55 46 (a, b) => 56 47 new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), 57 48 ) 58 - .slice((current - 1) * ITEMS_PER_PAGE, current * ITEMS_PER_PAGE); 49 + .slice(pageIndex * ITEMS_PER_PAGE, (pageIndex + 1) * ITEMS_PER_PAGE); 59 50 60 51 return ( 61 52 <Shell> ··· 89 80 </div> 90 81 </Timeline.Article> 91 82 ))} 92 - {current && total && ( 93 - <div className="grid grid-cols-1 gap-4 md:grid-cols-5 md:gap-6"> 94 - <div className="row-span-2" /> 95 - <div className="w-full md:order-2 md:col-span-4"> 96 - <Pagination> 97 - <PaginationContent> 98 - {Array.from({ length: total }).map((_, index) => { 99 - return ( 100 - <PaginationLink 101 - // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 102 - key={index} 103 - href={`?page=${index + 1}`} 104 - isActive={current === index + 1} 105 - > 106 - {index + 1} 107 - </PaginationLink> 108 - ); 109 - })} 110 - </PaginationContent> 111 - </Pagination> 112 - </div> 83 + <div className="grid grid-cols-1 gap-4 md:grid-cols-5 md:gap-6"> 84 + <div className="row-span-2" /> 85 + <div className="w-full md:order-2 md:col-span-4"> 86 + <Pagination> 87 + <PaginationContent> 88 + {Array.from({ length: MAX_PAGE_INDEX + 1 }).map((_, index) => { 89 + return ( 90 + <PaginationLink 91 + // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 92 + key={index} 93 + href={`?pageIndex=${index}`} 94 + isActive={pageIndex === index} 95 + > 96 + {index + 1} 97 + </PaginationLink> 98 + ); 99 + })} 100 + </PaginationContent> 101 + </Pagination> 113 102 </div> 114 - )} 103 + </div> 115 104 </Timeline> 116 105 </Shell> 117 106 );
+26
apps/web/src/app/(content)/blog/search-params.ts
··· 1 + import { allPosts } from "contentlayer/generated"; 2 + import { 3 + createParser, 4 + createSearchParamsCache, 5 + parseAsInteger, 6 + } from "nuqs/server"; 7 + 8 + const parseAsPageIndex = createParser({ 9 + parse(queryValue) { 10 + const parsed = parseAsInteger.parse(queryValue); 11 + if (!parsed || parsed < 0) return 0; 12 + return Math.min(parsed, MAX_PAGE_INDEX); 13 + }, 14 + serialize(value) { 15 + return value.toString(); 16 + }, 17 + }); 18 + 19 + export const ITEMS_PER_PAGE = 10; 20 + export const MAX_PAGE_INDEX = Math.ceil(allPosts.length / ITEMS_PER_PAGE) - 1; 21 + 22 + export const searchParamsParsers = { 23 + pageIndex: parseAsPageIndex.withDefault(0), 24 + }; 25 + 26 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+27 -38
apps/web/src/app/(content)/changelog/page.tsx
··· 15 15 import { allChangelogs } from "contentlayer/generated"; 16 16 import { Rss } from "lucide-react"; 17 17 import type { Metadata } from "next"; 18 - import { z } from "zod"; 18 + import { 19 + ITEMS_PER_PAGE, 20 + MAX_PAGE_INDEX, 21 + searchParamsCache, 22 + } from "./search-params"; 19 23 20 24 export const metadata: Metadata = { 21 25 ...defaultMetadata, ··· 30 34 }, 31 35 }; 32 36 33 - const searchParamsSchema = z.object({ 34 - page: z 35 - .string() 36 - .optional() 37 - .transform((val) => Number.parseInt(val || "1", 10)), 38 - }); 39 - 40 - const ITEMS_PER_PAGE = 10; 41 - 42 37 export default function ChangelogClient({ 43 38 searchParams, 44 39 }: { 45 40 searchParams: { [key: string]: string | string[] | undefined }; 46 41 }) { 47 - const search = searchParamsSchema.safeParse(searchParams); 48 - 49 - const page = search.data?.page; 50 - const current = !page ? 1 : page; 51 - const total = Math.ceil(allChangelogs.length / ITEMS_PER_PAGE); 42 + const { pageIndex } = searchParamsCache.parse(searchParams); 52 43 53 44 const changelogs = allChangelogs 54 45 .sort( 55 46 (a, b) => 56 47 new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), 57 48 ) 58 - .slice((current - 1) * ITEMS_PER_PAGE, current * ITEMS_PER_PAGE); 49 + .slice(pageIndex * ITEMS_PER_PAGE, (pageIndex + 1) * ITEMS_PER_PAGE); 59 50 60 51 return ( 61 52 <Shell> ··· 82 73 <Mdx code={changelog.body.code} /> 83 74 </Timeline.Article> 84 75 ))} 85 - {current && total && ( 86 - <div className="grid grid-cols-1 gap-4 md:grid-cols-5 md:gap-6"> 87 - <div className="row-span-2" /> 88 - <div className="w-full md:order-2 md:col-span-4"> 89 - <Pagination> 90 - <PaginationContent> 91 - {Array.from({ length: total }).map((_, index) => { 92 - return ( 93 - <PaginationLink 94 - // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 95 - key={index} 96 - href={`?page=${index + 1}`} 97 - isActive={current === index + 1} 98 - > 99 - {index + 1} 100 - </PaginationLink> 101 - ); 102 - })} 103 - </PaginationContent> 104 - </Pagination> 105 - </div> 76 + <div className="grid grid-cols-1 gap-4 md:grid-cols-5 md:gap-6"> 77 + <div className="row-span-2" /> 78 + <div className="w-full md:order-2 md:col-span-4"> 79 + <Pagination> 80 + <PaginationContent> 81 + {Array.from({ length: MAX_PAGE_INDEX + 1 }).map((_, index) => { 82 + return ( 83 + <PaginationLink 84 + // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 85 + key={index} 86 + href={`?pageIndex=${index}`} 87 + isActive={pageIndex === index} 88 + > 89 + {index + 1} 90 + </PaginationLink> 91 + ); 92 + })} 93 + </PaginationContent> 94 + </Pagination> 106 95 </div> 107 - )} 96 + </div> 108 97 </Timeline> 109 98 </Shell> 110 99 );
+27
apps/web/src/app/(content)/changelog/search-params.ts
··· 1 + import { allChangelogs } from "contentlayer/generated"; 2 + import { 3 + createParser, 4 + createSearchParamsCache, 5 + parseAsInteger, 6 + } from "nuqs/server"; 7 + 8 + const parseAsPageIndex = createParser({ 9 + parse(queryValue) { 10 + const parsed = parseAsInteger.parse(queryValue); 11 + if (!parsed || parsed < 0) return 0; 12 + return Math.min(parsed, MAX_PAGE_INDEX); 13 + }, 14 + serialize(value) { 15 + return value.toString(); 16 + }, 17 + }); 18 + 19 + export const ITEMS_PER_PAGE = 10; 20 + export const MAX_PAGE_INDEX = 21 + Math.ceil(allChangelogs.length / ITEMS_PER_PAGE) - 1; 22 + 23 + export const searchParamsParsers = { 24 + pageIndex: parseAsPageIndex.withDefault(0), 25 + }; 26 + 27 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+2 -10
apps/web/src/app/app/(auth)/login/page.tsx
··· 1 1 import Link from "next/link"; 2 - import { z } from "zod"; 3 2 4 3 import { Button, Separator } from "@openstatus/ui"; 5 4 ··· 8 7 import { Icons } from "@/components/icons"; 9 8 import { signIn } from "@/lib/auth"; 10 9 import MagicLinkForm from "./_components/magic-link-form"; 10 + import { searchParamsCache } from "./search-params"; 11 11 12 12 const isDev = process.env.NODE_ENV === "development"; 13 13 14 - /** 15 - * allowed URL search params 16 - */ 17 - const searchParamsSchema = z.object({ 18 - redirectTo: z.string().optional().default("/app"), 19 - }); 20 - 21 14 export default function Page({ 22 15 searchParams, 23 16 }: { 24 17 searchParams: { [key: string]: string | string[] | undefined }; 25 18 }) { 26 - const search = searchParamsSchema.safeParse(searchParams); 27 - const redirectTo = search.success ? search.data.redirectTo : "/app"; 19 + const { redirectTo } = searchParamsCache.parse(searchParams); 28 20 29 21 return ( 30 22 <Shell className="my-4 grid w-full max-w-xl gap-6 md:p-10">
+7
apps/web/src/app/app/(auth)/login/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + redirectTo: parseAsString.withDefault("/app"), 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+7 -31
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/page.tsx
··· 1 1 import Link from "next/link"; 2 - import { notFound } from "next/navigation"; 3 - import { z } from "zod"; 4 2 5 3 import { OSTinybird } from "@openstatus/tinybird"; 6 4 import { Button } from "@openstatus/ui/src/components/button"; ··· 11 9 import { DataTable } from "@/components/data-table/monitor/data-table"; 12 10 import { env } from "@/env"; 13 11 import { api } from "@/trpc/server"; 12 + import { searchParamsCache } from "./search-params"; 14 13 15 14 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 16 15 17 - /** 18 - * allowed URL search params 19 - */ 20 - const searchParamsSchema = z.object({ 21 - tags: z 22 - .string() 23 - .transform((v) => v?.split(",")) 24 - .optional(), 25 - public: z 26 - .string() 27 - .transform((v) => 28 - v?.split(",").map((v) => { 29 - if (v === "true") return true; 30 - if (v === "false") return false; 31 - return undefined; 32 - }), 33 - ) 34 - .optional(), 35 - pageSize: z.coerce.number().optional().default(10), 36 - pageIndex: z.coerce.number().optional().default(0), 37 - }); 38 - 39 16 export default async function MonitorPage({ 40 17 searchParams, 41 18 }: { 42 19 searchParams: { [key: string]: string | string[] | undefined }; 43 20 }) { 44 - const search = searchParamsSchema.safeParse(searchParams); 45 - if (!search.success) return notFound(); 21 + const search = searchParamsCache.parse(searchParams); 46 22 47 23 const monitors = await api.monitor.getMonitorsByWorkspace.query(); 48 24 if (monitors?.length === 0) ··· 117 93 <> 118 94 <DataTable 119 95 defaultColumnFilters={[ 120 - { id: "tags", value: search.data.tags }, 121 - { id: "public", value: search.data.public }, 122 - ].filter((v) => v.value !== undefined)} 96 + { id: "tags", value: search.tags }, 97 + { id: "public", value: search.public }, 98 + ].filter((v) => v.value !== undefined || v.value !== null)} 123 99 columns={columns} 124 100 data={monitorsWithData} 125 101 tags={tags} 126 102 defaultPagination={{ 127 - pageIndex: search.data.pageIndex, 128 - pageSize: search.data.pageSize, 103 + pageIndex: search.pageIndex, 104 + pageSize: search.pageSize, 129 105 }} 130 106 /> 131 107 {isLimitReached ? <Limit /> : null}
+16
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/search-params.ts
··· 1 + import { 2 + createSearchParamsCache, 3 + parseAsArrayOf, 4 + parseAsBoolean, 5 + parseAsInteger, 6 + parseAsString, 7 + } from "nuqs/server"; 8 + 9 + export const searchParamsParsers = { 10 + tags: parseAsArrayOf(parseAsString), 11 + public: parseAsArrayOf(parseAsBoolean), 12 + pageSize: parseAsInteger.withDefault(10), 13 + pageIndex: parseAsInteger.withDefault(0), 14 + }; 15 + 16 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+11 -42
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 import * as React from "react"; 3 - import * as z from "zod"; 4 3 5 4 import { OSTinybird } from "@openstatus/tinybird"; 6 5 7 6 import { DatePickerPreset } from "@/components/monitor-dashboard/date-picker-preset"; 8 7 import { env } from "@/env"; 9 - import { periods } from "@/lib/monitor/utils"; 10 8 import { api } from "@/trpc/server"; 11 9 import { DataTableWrapper } from "./_components/data-table-wrapper"; 12 - import { DownloadCSVButton } from "./_components/download-csv-button"; 10 + import { searchParamsCache } from "./search-params"; 13 11 14 12 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 15 13 16 - /** 17 - * allowed URL search params 18 - */ 19 - const searchParamsSchema = z.object({ 20 - period: z.enum(periods).optional().default("1h"), 21 - // improve coersion + array + ... 22 - region: z 23 - .string() 24 - .optional() 25 - .transform((val) => { 26 - return val?.split(","); 27 - }), 28 - statusCode: z 29 - .string() 30 - .optional() 31 - .transform((val) => { 32 - return val?.split(",").map(Number.parseInt); 33 - }), 34 - error: z 35 - .string() 36 - .optional() 37 - .transform((val) => { 38 - return val?.split(",").map((v) => v === "true"); 39 - }), 40 - pageSize: z.coerce.number().optional().default(10), 41 - pageIndex: z.coerce.number().optional().default(0), 42 - }); 43 - 44 14 export default async function Page({ 45 15 params, 46 16 searchParams, ··· 49 19 searchParams: { [key: string]: string | string[] | undefined }; 50 20 }) { 51 21 const id = params.id; 52 - const search = searchParamsSchema.safeParse(searchParams); 22 + const search = searchParamsCache.parse(searchParams); 53 23 54 24 const monitor = await api.monitor.getMonitorById.query({ 55 25 id: Number(id), 56 26 }); 57 27 58 - if (!monitor || !search.success) { 59 - return notFound(); // maybe not if search.success is false, add a toast message 60 - } 28 + if (!monitor) return notFound(); 61 29 62 30 const allowedPeriods = ["1h", "1d", "3d", "7d"] as const; 63 - const period = allowedPeriods.find((i) => i === search.data.period) || "1d"; 31 + const period = allowedPeriods.find((i) => i === search.period) || "1d"; 64 32 65 33 const data = await tb.endpointList(period)({ monitorId: id }); 66 34 ··· 77 45 }`} 78 46 /> */} 79 47 </div> 48 + {/* FIXME: we display all the regions even though a user might not have all supported in their plan */} 80 49 <DataTableWrapper 81 50 data={data} 82 51 filters={[ 83 - { id: "statusCode", value: search.data.statusCode }, 84 - { id: "region", value: search.data.region }, 85 - { id: "error", value: search.data.error }, 86 - ].filter((v) => v.value !== undefined)} 52 + { id: "statusCode", value: search.statusCode }, 53 + { id: "region", value: search.regions }, 54 + { id: "error", value: search.error }, 55 + ].filter((v) => v.value !== null)} 87 56 pagination={{ 88 - pageIndex: search.data.pageIndex, 89 - pageSize: search.data.pageSize, 57 + pageIndex: search.pageIndex, 58 + pageSize: search.pageSize, 90 59 }} 91 60 /> 92 61 </div>
+22
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/search-params.ts
··· 1 + import { periods } from "@/lib/monitor/utils"; 2 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 3 + import { 4 + createSearchParamsCache, 5 + parseAsArrayOf, 6 + parseAsBoolean, 7 + parseAsInteger, 8 + parseAsStringLiteral, 9 + } from "nuqs/server"; 10 + 11 + export const DEFAULT_PERIOD = "1d"; 12 + 13 + export const searchParamsParsers = { 14 + statusCode: parseAsArrayOf(parseAsInteger), 15 + cronTimestamp: parseAsInteger, 16 + error: parseAsArrayOf(parseAsBoolean), 17 + period: parseAsStringLiteral(periods).withDefault(DEFAULT_PERIOD), 18 + regions: parseAsArrayOf(parseAsStringLiteral(flyRegions)), 19 + pageSize: parseAsInteger.withDefault(10), 20 + pageIndex: parseAsInteger.withDefault(0), 21 + }; 22 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+5 -17
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/details/page.tsx
··· 1 1 import Link from "next/link"; 2 - import * as z from "zod"; 3 2 4 3 import { Button } from "@openstatus/ui/src/components/button"; 5 4 6 5 import { EmptyState } from "@/components/dashboard/empty-state"; 7 6 import { ResponseDetails } from "@/components/monitor-dashboard/response-details"; 8 7 import { api } from "@/trpc/server"; 9 - import { monitorFlyRegionSchema } from "@openstatus/db/src/schema/constants"; 10 - // 11 - 12 - /** 13 - * allowed URL search params 14 - */ 15 - const searchParamsSchema = z.object({ 16 - monitorId: z.string(), 17 - url: z.string(), 18 - region: monitorFlyRegionSchema.optional(), 19 - cronTimestamp: z.coerce.number(), 20 - }); 8 + import { searchParamsCache } from "./search-params"; 21 9 22 10 export default async function Details({ 23 11 // biome-ignore lint/correctness/noUnusedVariables: <explanation> ··· 27 15 params: { id: string; workspaceSlug: string }; 28 16 searchParams: { [key: string]: string | string[] | undefined }; 29 17 }) { 30 - const search = searchParamsSchema.safeParse(searchParams); 18 + const search = searchParamsCache.parse(searchParams); 31 19 32 - if (!search.success) return <PageEmptyState />; 20 + if (!search.monitorId) return <PageEmptyState />; 33 21 34 22 try { 35 23 await api.monitor.getMonitorById.query({ 36 - id: Number.parseInt(search.data.monitorId), 24 + id: Number.parseInt(search.monitorId), 37 25 }); 38 - return <ResponseDetails {...search.data} />; 26 + return <ResponseDetails {...search} />; 39 27 } catch (_e) { 40 28 return <PageEmptyState />; 41 29 }
+15
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/details/search-params.ts
··· 1 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 2 + import { 3 + createSearchParamsCache, 4 + parseAsInteger, 5 + parseAsString, 6 + parseAsStringLiteral, 7 + } from "nuqs/server"; 8 + 9 + export const searchParamsParsers = { 10 + monitorId: parseAsString.withDefault(""), 11 + url: parseAsString.withDefault(""), 12 + region: parseAsStringLiteral(flyRegions).withDefault("ams"), 13 + cronTimestamp: parseAsInteger.withDefault(0), 14 + }; 15 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+4 -9
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/page.tsx
··· 1 - import { z } from "zod"; 2 - 3 1 import { MonitorForm } from "@/components/forms/monitor/form"; 4 2 import { api } from "@/trpc/server"; 5 - 6 - const searchParamsSchema = z.object({ 7 - section: z.string().optional().default("request"), 8 - }); 3 + import { searchParamsCache } from "./search-params"; 9 4 10 5 export default async function EditPage({ 11 6 params, ··· 28 23 29 24 const tags = await api.monitorTag.getMonitorTagsByWorkspace.query(); 30 25 31 - // default is request 32 - const search = searchParamsSchema.safeParse(searchParams); 26 + const { section } = searchParamsCache.parse(searchParams); 27 + 33 28 return ( 34 29 <MonitorForm 35 - defaultSection={search.success ? search.data.section : undefined} 30 + defaultSection={section} 36 31 defaultValues={{ 37 32 ...monitor, 38 33 // FIXME - Why is this not working?
+7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + section: parseAsString.withDefault("request"), 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+11 -41
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 import * as React from "react"; 3 - import * as z from "zod"; 4 3 5 4 import { flyRegions } from "@openstatus/db/src/schema/constants"; 6 5 import type { Region } from "@openstatus/tinybird"; ··· 12 11 import { DatePickerPreset } from "@/components/monitor-dashboard/date-picker-preset"; 13 12 import { Metrics } from "@/components/monitor-dashboard/metrics"; 14 13 import { env } from "@/env"; 15 - import { 16 - getMinutesByInterval, 17 - intervals, 18 - periods, 19 - quantiles, 20 - } from "@/lib/monitor/utils"; 14 + import { getMinutesByInterval, periods } from "@/lib/monitor/utils"; 21 15 import { getPreferredSettings } from "@/lib/preferred-settings/server"; 22 16 import { api } from "@/trpc/server"; 17 + import { 18 + DEFAULT_INTERVAL, 19 + DEFAULT_PERIOD, 20 + DEFAULT_QUANTILE, 21 + searchParamsCache, 22 + } from "./search-params"; 23 23 24 24 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 25 25 26 - const DEFAULT_QUANTILE = "p95"; 27 - const DEFAULT_INTERVAL = "30m"; 28 - const DEFAULT_PERIOD = "1d"; 29 - 30 - /** 31 - * allowed URL search params 32 - */ 33 - const searchParamsSchema = z.object({ 34 - statusCode: z.coerce.number().optional(), 35 - cronTimestamp: z.coerce.number().optional(), 36 - quantile: z.enum(quantiles).optional().default(DEFAULT_QUANTILE), 37 - interval: z.enum(intervals).optional().default(DEFAULT_INTERVAL), 38 - period: z.enum(periods).optional().default(DEFAULT_PERIOD), 39 - regions: z 40 - .string() 41 - .optional() 42 - .transform( 43 - (value) => 44 - value 45 - ?.trim() 46 - ?.split(",") 47 - .filter((i) => flyRegions.includes(i as Region)) ?? [], 48 - ), 49 - }); 50 - 51 26 export default async function Page({ 52 27 params, 53 28 searchParams, ··· 56 31 searchParams: { [key: string]: string | string[] | undefined }; 57 32 }) { 58 33 const id = params.id; 59 - const search = searchParamsSchema.safeParse(searchParams); 34 + const search = searchParamsCache.parse(searchParams); 60 35 61 - if (!search.success) { 62 - return notFound(); 63 - } 64 36 const preferredSettings = getPreferredSettings(); 65 37 66 38 const monitor = await api.monitor.getMonitorById.query({ 67 39 id: Number(id), 68 40 }); 69 41 70 - if (!monitor) { 71 - return notFound(); 72 - } 42 + if (!monitor) return notFound(); 73 43 74 - const { period, quantile, interval, regions } = search.data; 44 + const { period, quantile, interval, regions } = search; 75 45 76 46 // TODO: work it out easier 77 47 const intervalMinutes = getMinutesByInterval(interval); ··· 97 67 period !== DEFAULT_PERIOD || 98 68 quantile !== DEFAULT_QUANTILE || 99 69 interval !== DEFAULT_INTERVAL || 100 - regions.length > 0; 70 + flyRegions.length !== regions.length; 101 71 102 72 // GET VALUES FOR BLOG POST 103 73 // console.log(
+24
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/search-params.ts
··· 1 + import { intervals, periods, quantiles } from "@/lib/monitor/utils"; 2 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 3 + import { 4 + createSearchParamsCache, 5 + parseAsArrayOf, 6 + parseAsInteger, 7 + parseAsStringLiteral, 8 + } from "nuqs/server"; 9 + 10 + export const DEFAULT_QUANTILE = "p95"; 11 + export const DEFAULT_INTERVAL = "30m"; 12 + export const DEFAULT_PERIOD = "1d"; 13 + 14 + export const searchParamsParsers = { 15 + statusCode: parseAsInteger, 16 + cronTimestamp: parseAsInteger, 17 + quantile: parseAsStringLiteral(quantiles).withDefault(DEFAULT_QUANTILE), 18 + interval: parseAsStringLiteral(intervals).withDefault(DEFAULT_INTERVAL), 19 + period: parseAsStringLiteral(periods).withDefault(DEFAULT_PERIOD), 20 + regions: parseAsArrayOf(parseAsStringLiteral(flyRegions)).withDefault([ 21 + ...flyRegions, 22 + ]), 23 + }; 24 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+3 -7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/new/page.tsx
··· 1 1 import { redirect } from "next/navigation"; 2 - import { z } from "zod"; 3 2 4 3 import { MonitorForm } from "@/components/forms/monitor/form"; 5 4 import { api } from "@/trpc/server"; 6 - 7 - const searchParamsSchema = z.object({ 8 - section: z.string().optional().default("request"), 9 - }); 5 + import { searchParamsCache } from "./search-params"; 10 6 11 7 export default async function Page({ 12 8 searchParams, ··· 23 19 24 20 if (isLimitReached) return redirect("./"); 25 21 26 - const search = searchParamsSchema.safeParse(searchParams); 22 + const { section } = searchParamsCache.parse(searchParams); 27 23 28 24 return ( 29 25 <MonitorForm 30 - defaultSection={search.success ? search.data.section : undefined} 26 + defaultSection={section} 31 27 notifications={notifications} 32 28 pages={pages} 33 29 tags={tags}
+7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/new/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + section: parseAsString.withDefault("request"), 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+2 -6
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/page.tsx
··· 4 4 import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 5 5 import { PagerDutySchema } from "@openstatus/notification-pagerduty"; 6 6 7 - import { z } from "zod"; 7 + import { searchParamsCache } from "./search-params"; 8 8 9 9 // REMINDER: PagerDuty requires a different workflow, thus the separate page 10 - 11 - const searchParamsSchema = z.object({ 12 - config: z.string().optional(), 13 - }); 14 10 15 11 export default async function PagerDutyPage({ 16 12 searchParams, ··· 19 15 }) { 20 16 const workspace = await api.workspace.getWorkspace.query(); 21 17 const monitors = await api.monitor.getMonitorsByWorkspace.query(); 22 - const params = searchParamsSchema.parse(searchParams); 18 + const params = searchParamsCache.parse(searchParams); 23 19 24 20 if (!params.config) { 25 21 return <div>Invalid data</div>;
+7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + config: parseAsString, 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+5 -9
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/page.tsx
··· 1 1 import * as React from "react"; 2 2 3 3 import { api } from "@/trpc/server"; 4 - import { z } from "zod"; 5 4 import { PathCard } from "./_components/path-card"; 6 5 import { SessionTable } from "./_components/session-table"; 7 - 8 - const searchParamsSchema = z.object({ 9 - path: z.string(), 10 - }); 6 + import { searchParamsCache } from "./search-params"; 11 7 12 8 export default async function RUMPage({ 13 9 searchParams, 14 10 }: { 15 11 searchParams: { [key: string]: string | string[] | undefined }; 16 12 }) { 17 - const search = searchParamsSchema.safeParse(searchParams); 13 + const { path } = searchParamsCache.parse(searchParams); 18 14 19 15 const applications = await api.workspace.getApplicationWorkspaces.query(); 20 16 const dsn = applications?.[0]?.dsn; 21 17 22 - if (!search.success || !dsn) return null; 18 + if (!path || !dsn) return null; 23 19 24 20 return ( 25 21 <> 26 - <PathCard dsn={dsn} path={search.data.path} /> 22 + <PathCard dsn={dsn} path={path} /> 27 23 <div> 28 - <SessionTable dsn={dsn} path={search.data.path} /> 24 + <SessionTable dsn={dsn} path={path} /> 29 25 </div> 30 26 </> 31 27 );
+7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/overview/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + path: parseAsString, 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+3 -8
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 - import { z } from "zod"; 3 2 4 3 import { StatusPageForm } from "@/components/forms/status-page/form"; 5 4 import { api } from "@/trpc/server"; 6 - 7 - const searchParamsSchema = z.object({ 8 - section: z.string().optional().default("monitors"), 9 - }); 5 + import { searchParamsCache } from "./search-params"; 10 6 11 7 export default async function EditPage({ 12 8 params, ··· 24 20 return notFound(); 25 21 } 26 22 27 - // default is request 28 - const search = searchParamsSchema.safeParse(searchParams); 23 + const { section } = searchParamsCache.parse(searchParams); 29 24 30 25 return ( 31 26 <StatusPageForm ··· 37 32 order, 38 33 })), 39 34 }} 40 - defaultSection={search.success ? search.data.section : undefined} 35 + defaultSection={section} 41 36 plan={workspace.plan} 42 37 workspaceSlug={params.workspaceSlug} 43 38 />
+7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + section: parseAsString.withDefault("monitors"), 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+4 -12
apps/web/src/app/app/invite/page.tsx
··· 1 - import { z } from "zod"; 2 - 3 1 import { Alert, AlertDescription, AlertTitle, Separator } from "@openstatus/ui"; 4 2 5 3 import { Icons } from "@/components/icons"; 6 4 import { api } from "@/trpc/server"; 7 5 import { LinkCards } from "./_components/link-cards"; 6 + import { searchParamsCache } from "./search-params"; 8 7 9 8 const AlertTriangle = Icons["alert-triangle"]; 10 9 11 - /** 12 - * allowed URL search params 13 - */ 14 - const searchParamsSchema = z.object({ 15 - token: z.string(), 16 - }); 17 - 18 10 export default async function InvitePage({ 19 11 searchParams, 20 12 }: { 21 13 searchParams: { [key: string]: string | string[] | undefined }; 22 14 }) { 23 - const search = searchParamsSchema.safeParse(searchParams); 24 - const { message, data } = search.success 25 - ? await api.invitation.acceptInvitation.mutate({ token: search.data.token }) 15 + const { token } = searchParamsCache.parse(searchParams); 16 + const { message, data } = token 17 + ? await api.invitation.acceptInvitation.mutate({ token }) 26 18 : { message: "Unavailable invitation token.", data: undefined }; 27 19 28 20 const workspace = await api.workspace.getWorkspace.query();
+7
apps/web/src/app/app/invite/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + token: parseAsString, 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+4 -26
apps/web/src/app/play/checker/[id]/page.tsx
··· 1 1 import type { Metadata } from "next"; 2 2 import Link from "next/link"; 3 3 import { redirect } from "next/navigation"; 4 - import * as z from "zod"; 5 4 6 - import { 7 - flyRegions, 8 - monitorFlyRegionSchema, 9 - } from "@openstatus/db/src/schema/constants"; 5 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 10 6 import { Separator } from "@openstatus/ui"; 11 7 12 8 import { ··· 21 17 getCheckerDataById, 22 18 timestampFormatter, 23 19 } from "@/components/ping-response-analysis/utils"; 24 - import type { Region } from "@openstatus/tinybird"; 25 - 26 - /** 27 - * allowed URL search params 28 - */ 29 - const searchParamsSchema = z.object({ 30 - regions: z 31 - .string() 32 - .optional() 33 - .transform( 34 - (value) => 35 - value 36 - ?.trim() 37 - ?.split(",") 38 - .filter((i) => flyRegions.includes(i as Region)) ?? flyRegions, 39 - ) 40 - .pipe(monitorFlyRegionSchema.array().optional()), 41 - }); 20 + import { searchParamsCache } from "./search-params"; 42 21 43 22 interface Props { 44 23 params: { id: string }; ··· 46 25 } 47 26 48 27 export default async function CheckPage({ params, searchParams }: Props) { 49 - const search = searchParamsSchema.safeParse(searchParams); 50 - 51 - const selectedRegions = search.success ? search.data.regions : undefined; 28 + const { regions } = searchParamsCache.parse(searchParams); 29 + const selectedRegions = regions || [...flyRegions]; 52 30 53 31 const data = await getCheckerDataById(params.id); 54 32
+13
apps/web/src/app/play/checker/[id]/search-params.ts
··· 1 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 2 + import { 3 + createSearchParamsCache, 4 + parseAsArrayOf, 5 + parseAsString, 6 + parseAsStringLiteral, 7 + } from "nuqs/server"; 8 + 9 + export const searchParamsParsers = { 10 + regions: parseAsArrayOf(parseAsStringLiteral(flyRegions)), 11 + }; 12 + 13 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+7 -16
apps/web/src/app/play/checker/_components/checker-form.tsx
··· 1 1 "use client"; 2 2 3 3 import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { usePathname, useRouter, useSearchParams } from "next/navigation"; 5 - import { useMemo, useState, useTransition } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { useState, useTransition } from "react"; 6 6 import { useForm } from "react-hook-form"; 7 7 import * as z from "zod"; 8 8 ··· 47 47 regionCheckerSchema, 48 48 regionFormatter, 49 49 } from "@/components/ping-response-analysis/utils"; 50 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 51 50 import { toast } from "@/lib/toast"; 52 51 import { notEmpty } from "@/lib/utils"; 53 52 import { flyRegions } from "@openstatus/db/src/schema/constants"; ··· 60 59 } from "lucide-react"; 61 60 import dynamic from "next/dynamic"; 62 61 import Link from "next/link"; 62 + import { useQueryStates } from "nuqs"; 63 + import { searchParamsParsers } from "../search-params"; 63 64 64 65 const FloatingActionNoSSR = dynamic( 65 66 () => ··· 91 92 } 92 93 93 94 export function CheckerForm({ defaultValues, defaultData }: CheckerFormProps) { 94 - const pathname = usePathname(); 95 95 const router = useRouter(); 96 96 const [isPending, startTransition] = useTransition(); 97 97 const form = useForm<FormSchema>({ ··· 99 99 defaultValues, 100 100 }); 101 101 const [result, setResult] = useState<RegionChecker[]>(defaultData || []); 102 - const updateSearchParams = useUpdateSearchParams(); 103 - const searchParams = useSearchParams(); 104 - 105 - const id = useMemo(() => { 106 - return searchParams.get("id"); 107 - }, [searchParams]); 102 + const [{ id }, setSearchParams] = useQueryStates(searchParamsParsers); 108 103 109 104 function onSubmit(data: FormSchema) { 110 105 startTransition(async () => { ··· 153 148 duration: 2000, 154 149 }); 155 150 } else { 156 - const searchParams = updateSearchParams({ 157 - id: item, 158 - }); 159 - router.replace(`${pathname}?${searchParams}`); 151 + setSearchParams({ id: item }); 160 152 toast.success("Data is available!", { 161 153 id: toastId, 162 154 duration: 3000, ··· 199 191 } 200 192 } 201 193 } catch (_e) { 202 - const searchParams = updateSearchParams({ id: null }); 203 - router.replace(`${pathname}?${searchParams}`); 194 + setSearchParams({ id: null }); 204 195 toast.error("Something went wrong", { 205 196 description: "Please try again", 206 197 id: toastId,
+2 -8
apps/web/src/app/play/checker/page.tsx
··· 3 3 import { BottomCTA } from "@/components/marketing/in-between-cta"; 4 4 import { getCheckerDataById } from "@/components/ping-response-analysis/utils"; 5 5 import { redirect } from "next/navigation"; 6 - import { z } from "zod"; 7 6 import CheckerPlay from "./_components/checker-play"; 8 7 import { GlobalMonitoring } from "./_components/global-monitoring"; 9 8 import { Testimonial } from "./_components/testimonial"; 9 + import { searchParamsCache } from "./search-params"; 10 10 11 11 export const metadata: Metadata = { 12 12 title: "Global Speed Checker", ··· 19 19 }, 20 20 }; 21 21 22 - const searchParamsSchema = z.object({ 23 - id: z.string().optional(), 24 - }); 25 - 26 22 export default async function PlayPage({ 27 23 searchParams, 28 24 }: { 29 25 searchParams: { [key: string]: string | string[] | undefined }; 30 26 }) { 31 - const search = searchParamsSchema.safeParse(searchParams); 32 - 33 - const id = search.success ? search.data.id : undefined; 27 + const { id } = searchParamsCache.parse(searchParams); 34 28 35 29 const data = id ? await getCheckerDataById(id) : null; 36 30
+7
apps/web/src/app/play/checker/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + id: parseAsString, 5 + }; 6 + 7 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-104
apps/web/src/app/play/status/_components/timezone-combobox.tsx
··· 1 - "use client"; 2 - 3 - import { Check, ChevronsUpDown } from "lucide-react"; 4 - import { usePathname, useRouter } from "next/navigation"; 5 - import * as React from "react"; 6 - 7 - import { 8 - Button, 9 - Command, 10 - CommandEmpty, 11 - CommandGroup, 12 - CommandInput, 13 - CommandItem, 14 - CommandList, 15 - Popover, 16 - PopoverContent, 17 - PopoverTrigger, 18 - } from "@openstatus/ui"; 19 - 20 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 21 - import { cn } from "@/lib/utils"; 22 - 23 - const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 24 - const supportedTimezones = Intl.supportedValuesOf("timeZone"); 25 - 26 - export function TimezoneCombobox({ defaultValue }: { defaultValue?: string }) { 27 - const [open, setOpen] = React.useState(false); 28 - const pathname = usePathname(); 29 - const router = useRouter(); 30 - const updateSearchParams = useUpdateSearchParams(); 31 - 32 - const value = defaultValue?.toLowerCase(); 33 - 34 - const timezones = supportedTimezones.map((timezone) => ({ 35 - value: timezone.toLowerCase(), 36 - label: timezone, 37 - })); 38 - 39 - return ( 40 - <Popover open={open} onOpenChange={setOpen}> 41 - <PopoverTrigger asChild> 42 - <Button 43 - variant="outline" 44 - role="combobox" 45 - aria-expanded={open} 46 - className="w-[220px] justify-between" 47 - > 48 - <span className="truncate"> 49 - {value 50 - ? timezones.find((timezone) => timezone.value === value)?.label 51 - : "Select timezone..."} 52 - {defaultValue?.toLowerCase() === currentTimezone?.toLowerCase() ? ( 53 - <span className="ml-1 font-light text-muted-foreground text-xs"> 54 - (default) 55 - </span> 56 - ) : null} 57 - </span> 58 - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 59 - </Button> 60 - </PopoverTrigger> 61 - <PopoverContent className="w-[300px] p-0"> 62 - <Command> 63 - <CommandInput placeholder="Search timezone..." /> 64 - <CommandList> 65 - <CommandEmpty>No timezone found.</CommandEmpty> 66 - <CommandGroup className="max-h-[300px] overflow-y-auto"> 67 - {timezones.map((timezone) => ( 68 - <CommandItem 69 - key={timezone.value} 70 - value={timezone.value} 71 - onSelect={(currentValue) => { 72 - setOpen(false); 73 - 74 - // update search params 75 - const searchParams = updateSearchParams({ 76 - timezone: 77 - currentValue === value 78 - ? null // remove search param and use default timezone 79 - : timezones.find( 80 - (timezone) => timezone.value === currentValue, 81 - )?.label || null, 82 - }); 83 - 84 - // refresh page with new search params 85 - router.replace(`${pathname}?${searchParams}`); 86 - router.refresh(); 87 - }} 88 - > 89 - <Check 90 - className={cn( 91 - "mr-2 h-4 w-4", 92 - value === timezone.value ? "opacity-100" : "opacity-0", 93 - )} 94 - /> 95 - {timezone.label} 96 - </CommandItem> 97 - ))} 98 - </CommandGroup> 99 - </CommandList> 100 - </Command> 101 - </PopoverContent> 102 - </Popover> 103 - ); 104 - }
+11 -38
apps/web/src/app/public/monitors/[id]/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 import * as React from "react"; 3 - import * as z from "zod"; 4 3 5 4 import { flyRegions } from "@openstatus/db/src/schema/constants"; 6 5 import type { Region } from "@openstatus/tinybird"; ··· 13 12 import { DatePickerPreset } from "@/components/monitor-dashboard/date-picker-preset"; 14 13 import { Metrics } from "@/components/monitor-dashboard/metrics"; 15 14 import { env } from "@/env"; 16 - import { 17 - getMinutesByInterval, 18 - intervals, 19 - quantiles, 20 - } from "@/lib/monitor/utils"; 15 + import { getMinutesByInterval } from "@/lib/monitor/utils"; 21 16 import { getPreferredSettings } from "@/lib/preferred-settings/server"; 22 17 import { api } from "@/trpc/server"; 18 + import { 19 + DEFAULT_INTERVAL, 20 + DEFAULT_PERIOD, 21 + DEFAULT_QUANTILE, 22 + periods, 23 + searchParamsCache, 24 + } from "./search-params"; 23 25 24 26 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 25 27 26 - const DEFAULT_QUANTILE = "p95"; 27 - const DEFAULT_INTERVAL = "30m"; 28 - const DEFAULT_PERIOD = "1d"; 29 - 30 - const periods = ["1d", "7d"] as const; // satisfies Period[] 31 - 32 - /** 33 - * allowed URL search params 34 - */ 35 - const searchParamsSchema = z.object({ 36 - statusCode: z.coerce.number().optional(), 37 - cronTimestamp: z.coerce.number().optional(), 38 - quantile: z.enum(quantiles).optional().default(DEFAULT_QUANTILE), 39 - interval: z.enum(intervals).optional().default(DEFAULT_INTERVAL), 40 - period: z.enum(periods).optional().default(DEFAULT_PERIOD), 41 - regions: z 42 - .string() 43 - .optional() 44 - .transform( 45 - (value) => 46 - value 47 - ?.trim() 48 - ?.split(",") 49 - .filter((i) => flyRegions.includes(i as Region)) ?? [], 50 - ), 51 - }); 52 - 53 28 export default async function Page({ 54 29 params, 55 30 searchParams, ··· 58 33 searchParams: { [key: string]: string | string[] | undefined }; 59 34 }) { 60 35 const id = params.id; 61 - const search = searchParamsSchema.safeParse(searchParams); 36 + const search = searchParamsCache.parse(searchParams); 62 37 const preferredSettings = getPreferredSettings(); 63 38 64 39 const monitor = await api.monitor.getPublicMonitorById.query({ 65 40 id: Number(id), 66 41 }); 67 42 68 - if (!monitor || !search.success) { 69 - return notFound(); 70 - } 43 + if (!monitor) return notFound(); 71 44 72 - const { period, quantile, interval, regions } = search.data; 45 + const { period, quantile, interval, regions } = search; 73 46 74 47 // TODO: work it out easier 75 48 const intervalMinutes = getMinutesByInterval(interval);
+26
apps/web/src/app/public/monitors/[id]/search-params.ts
··· 1 + import { intervals, quantiles } from "@/lib/monitor/utils"; 2 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 3 + import { 4 + createSearchParamsCache, 5 + parseAsArrayOf, 6 + parseAsInteger, 7 + parseAsStringLiteral, 8 + } from "nuqs/server"; 9 + 10 + export const DEFAULT_QUANTILE = "p95"; 11 + export const DEFAULT_INTERVAL = "30m"; 12 + export const DEFAULT_PERIOD = "1d"; 13 + 14 + export const periods = ["1d", "7d"] as const; 15 + 16 + export const searchParamsParsers = { 17 + statusCode: parseAsInteger, 18 + cronTimestamp: parseAsInteger, 19 + quantile: parseAsStringLiteral(quantiles).withDefault(DEFAULT_QUANTILE), 20 + interval: parseAsStringLiteral(intervals).withDefault(DEFAULT_INTERVAL), 21 + period: parseAsStringLiteral(periods).withDefault(DEFAULT_PERIOD), 22 + regions: parseAsArrayOf(parseAsStringLiteral(flyRegions)).withDefault([ 23 + ...flyRegions, 24 + ]), 25 + }; 26 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+2 -8
apps/web/src/app/status-page/[domain]/events/page.tsx
··· 3 3 import { Feed } from "@/components/status-page/feed"; 4 4 import { api } from "@/trpc/server"; 5 5 import { notFound } from "next/navigation"; 6 - import { z } from "zod"; 6 + import { searchParamsCache } from "./search-params"; 7 7 import { formatter } from "./utils"; 8 8 9 - const searchParamsSchema = z.object({ 10 - filter: z.enum(["all", "maintenances", "reports"]).optional().default("all"), 11 - }); 12 - 13 9 type Props = { 14 10 params: { domain: string }; 15 11 searchParams: { [key: string]: string | string[] | undefined }; ··· 18 14 export const revalidate = 120; 19 15 20 16 export default async function Page({ params, searchParams }: Props) { 17 + const { filter } = searchParamsCache.parse(searchParams); 21 18 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 22 - const search = searchParamsSchema.safeParse(searchParams); 23 19 24 20 if (!page) return notFound(); 25 - 26 - const filter = search.success ? search.data.filter : "all"; 27 21 28 22 return ( 29 23 <div className="grid gap-8">
+8
apps/web/src/app/status-page/[domain]/events/search-params.ts
··· 1 + import { createSearchParamsCache, parseAsStringLiteral } from "nuqs/server"; 2 + 3 + export const searchParamsParsers = { 4 + filter: parseAsStringLiteral(["all", "maintenances", "reports"]).withDefault( 5 + "all", 6 + ), 7 + }; 8 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+11 -38
apps/web/src/app/status-page/[domain]/monitors/[id]/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 import * as React from "react"; 3 - import * as z from "zod"; 4 3 5 4 import { flyRegions } from "@openstatus/db/src/schema/constants"; 6 5 import type { Region } from "@openstatus/tinybird"; ··· 13 12 import { DatePickerPreset } from "@/components/monitor-dashboard/date-picker-preset"; 14 13 import { Metrics } from "@/components/monitor-dashboard/metrics"; 15 14 import { env } from "@/env"; 16 - import { 17 - getMinutesByInterval, 18 - intervals, 19 - quantiles, 20 - } from "@/lib/monitor/utils"; 15 + import { getMinutesByInterval } from "@/lib/monitor/utils"; 21 16 import { getPreferredSettings } from "@/lib/preferred-settings/server"; 22 17 import { api } from "@/trpc/server"; 18 + import { 19 + DEFAULT_INTERVAL, 20 + DEFAULT_PERIOD, 21 + DEFAULT_QUANTILE, 22 + periods, 23 + searchParamsCache, 24 + } from "./search-params"; 23 25 24 26 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 25 27 26 - const periods = ["1d", "7d"] as const; // satisfies Period[] 27 - 28 - const DEFAULT_QUANTILE = "p95"; 29 - const DEFAULT_INTERVAL = "30m"; 30 - const DEFAULT_PERIOD = "7d"; 31 - 32 - /** 33 - * allowed URL search params 34 - */ 35 - const searchParamsSchema = z.object({ 36 - statusCode: z.coerce.number().optional(), 37 - cronTimestamp: z.coerce.number().optional(), 38 - quantile: z.enum(quantiles).optional().default(DEFAULT_QUANTILE), 39 - interval: z.enum(intervals).optional().default(DEFAULT_INTERVAL), 40 - period: z.enum(periods).optional().default(DEFAULT_PERIOD), 41 - regions: z 42 - .string() 43 - .optional() 44 - .transform( 45 - (value) => 46 - value 47 - ?.trim() 48 - ?.split(",") 49 - .filter((i) => flyRegions.includes(i as Region)) ?? [], 50 - ), 51 - }); 52 - 53 28 export const revalidate = 120; 54 29 55 30 export default async function Page({ ··· 60 35 searchParams: { [key: string]: string | string[] | undefined }; 61 36 }) { 62 37 const id = params.id; 63 - const search = searchParamsSchema.safeParse(searchParams); 38 + const search = searchParamsCache.parse(searchParams); 64 39 const preferredSettings = getPreferredSettings(); 65 40 66 41 const monitor = await api.monitor.getPublicMonitorById.query({ ··· 68 43 slug: params.domain, 69 44 }); 70 45 71 - if (!monitor || !search.success) { 72 - return notFound(); 73 - } 46 + if (!monitor) return notFound(); 74 47 75 - const { period, quantile, interval, regions } = search.data; 48 + const { period, quantile, interval, regions } = search; 76 49 77 50 // TODO: work it out easier 78 51 const intervalMinutes = getMinutesByInterval(interval);
+26
apps/web/src/app/status-page/[domain]/monitors/[id]/search-params.ts
··· 1 + import { intervals, quantiles } from "@/lib/monitor/utils"; 2 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 3 + import { 4 + createSearchParamsCache, 5 + parseAsArrayOf, 6 + parseAsInteger, 7 + parseAsStringLiteral, 8 + } from "nuqs/server"; 9 + 10 + export const DEFAULT_QUANTILE = "p95"; 11 + export const DEFAULT_INTERVAL = "30m"; 12 + export const DEFAULT_PERIOD = "1d"; 13 + 14 + export const periods = ["1d", "7d"] as const; 15 + 16 + export const searchParamsParsers = { 17 + statusCode: parseAsInteger, 18 + cronTimestamp: parseAsInteger, 19 + quantile: parseAsStringLiteral(quantiles).withDefault(DEFAULT_QUANTILE), 20 + interval: parseAsStringLiteral(intervals).withDefault(DEFAULT_INTERVAL), 21 + period: parseAsStringLiteral(periods).withDefault(DEFAULT_PERIOD), 22 + regions: parseAsArrayOf(parseAsStringLiteral(flyRegions)).withDefault([ 23 + ...flyRegions, 24 + ]), 25 + }; 26 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+3 -13
apps/web/src/app/status-page/[domain]/monitors/page.tsx
··· 1 1 import { ChevronRight } from "lucide-react"; 2 2 import Link from "next/link"; 3 3 import { notFound } from "next/navigation"; 4 - import { z } from "zod"; 5 4 6 5 import { OSTinybird } from "@openstatus/tinybird"; 7 6 import { Button } from "@openstatus/ui/src/components/button"; ··· 11 10 import { SimpleChart } from "@/components/monitor-charts/simple-chart"; 12 11 import { groupDataByTimestamp } from "@/components/monitor-charts/utils"; 13 12 import { env } from "@/env"; 14 - import { quantiles } from "@/lib/monitor/utils"; 15 13 import { api } from "@/trpc/server"; 14 + import { searchParamsCache } from "./search-params"; 16 15 17 16 // Add loading page 18 17 19 - /** 20 - * allowed URL search params 21 - */ 22 - const searchParamsSchema = z.object({ 23 - quantile: z.enum(quantiles).optional().default("p95"), 24 - period: z.enum(["7d"]).optional().default("7d"), 25 - }); 26 - 27 18 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 28 19 29 20 export const revalidate = 120; ··· 36 27 searchParams: { [key: string]: string | string[] | undefined }; 37 28 }) { 38 29 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 39 - const search = searchParamsSchema.safeParse(searchParams); 40 - if (!page || !search.success) notFound(); 30 + const { quantile, period } = searchParamsCache.parse(searchParams); 41 31 42 - const { quantile, period } = search.data; 32 + if (!page) notFound(); 43 33 44 34 // filter monitor by public or not 45 35
+8
apps/web/src/app/status-page/[domain]/monitors/search-params.ts
··· 1 + import { quantiles } from "@/lib/monitor/utils"; 2 + import { createSearchParamsCache, parseAsStringLiteral } from "nuqs/server"; 3 + 4 + export const searchParamsParsers = { 5 + quantile: parseAsStringLiteral(quantiles).withDefault("p95"), 6 + period: parseAsStringLiteral(["7d"]).withDefault("7d"), 7 + }; 8 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-93
apps/web/src/components/data-table/data-table-date-ranger-picker.tsx
··· 1 - "use client"; 2 - 3 - import { format } from "date-fns"; 4 - import { Calendar as CalendarIcon } from "lucide-react"; 5 - import { usePathname, useRouter, useSearchParams } from "next/navigation"; 6 - import * as React from "react"; 7 - import type { DateRange } from "react-day-picker"; 8 - 9 - import { 10 - Button, 11 - Calendar, 12 - Popover, 13 - PopoverContent, 14 - PopoverTrigger, 15 - } from "@openstatus/ui"; 16 - 17 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 18 - import { cn, manipulateDate } from "@/lib/utils"; 19 - 20 - type DataTableDateRangePicker = React.HTMLAttributes<HTMLDivElement>; 21 - 22 - export function DataTableDateRangePicker({ 23 - className, 24 - }: DataTableDateRangePicker) { 25 - const router = useRouter(); 26 - const pathname = usePathname(); 27 - const searchParams = useSearchParams(); 28 - const updateSearchParams = useUpdateSearchParams(); 29 - const [date, setDate] = React.useState<DateRange | undefined>(); 30 - 31 - React.useEffect(() => { 32 - if (searchParams) { 33 - const from = 34 - (searchParams.has("fromDate") && searchParams.get("fromDate")) || 35 - undefined; 36 - const to = 37 - (searchParams.has("toDate") && searchParams.get("toDate")) || undefined; 38 - setDate({ 39 - from: from ? new Date(Number(from)) : undefined, 40 - to: to ? new Date(Number(to)) : undefined, 41 - }); 42 - } 43 - }, [searchParams]); 44 - 45 - return ( 46 - <div className={cn("grid gap-2", className)}> 47 - <Popover> 48 - <PopoverTrigger asChild> 49 - <Button 50 - id="date" 51 - variant="outline" 52 - className={cn( 53 - "w-[260px] justify-start text-left font-normal", 54 - !date && "text-muted-foreground", 55 - )} 56 - > 57 - <CalendarIcon className="mr-2 h-4 w-4" /> 58 - {date?.from ? ( 59 - date.to ? ( 60 - <> 61 - {format(date.from, "LLL dd, y")} -{" "} 62 - {format(date.to, "LLL dd, y")} 63 - </> 64 - ) : ( 65 - format(date.from, "LLL dd, y") 66 - ) 67 - ) : ( 68 - <span>Pick a date</span> 69 - )} 70 - </Button> 71 - </PopoverTrigger> 72 - <PopoverContent className="w-auto p-0" align="start"> 73 - <Calendar 74 - initialFocus 75 - mode="range" 76 - defaultMonth={date?.from} 77 - selected={date} 78 - onSelect={(value) => { 79 - setDate(value); 80 - const { fromDate, toDate } = manipulateDate(value); 81 - 82 - const searchParams = updateSearchParams({ 83 - fromDate, 84 - toDate, 85 - }); 86 - router.replace(`${pathname}?${searchParams}`); 87 - }} 88 - /> 89 - </PopoverContent> 90 - </Popover> 91 - </div> 92 - ); 93 - }
+10 -23
apps/web/src/components/data-table/data-table-faceted-filter.tsx
··· 1 - import { useRouter } from "next/navigation"; 1 + "use client"; 2 2 3 3 import { 4 4 Badge, ··· 18 18 import type { Column } from "@tanstack/react-table"; 19 19 import { Check, PlusCircle } from "lucide-react"; 20 20 21 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 22 21 import { cn } from "@/lib/utils"; 22 + import { useQueryState } from "nuqs"; 23 23 24 24 interface DataTableFacetedFilter<TData, TValue> { 25 25 column?: Column<TData, TValue>; ··· 36 36 title, 37 37 options, 38 38 }: DataTableFacetedFilter<TData, TValue>) { 39 - const router = useRouter(); 40 - const updateSearchParams = useUpdateSearchParams(); 39 + // REMINDER: should have a default value for the column 40 + if (!column) throw new Error("Column is required."); 41 41 42 - const facets = column?.getFacetedUniqueValues(); 42 + const [_, setValue] = useQueryState(column.id, { shallow: false }); 43 + const facets = column.getFacetedUniqueValues(); 43 44 const selectedValues = new Set( 44 - column?.getFilterValue() as (string | number | boolean)[], 45 + column.getFilterValue() as (string | number | boolean)[], 45 46 ); 46 47 47 - const updatePageSearchParams = ( 48 - values: Record<string, number | string | null>, 49 - ) => { 50 - const newSearchParams = updateSearchParams(values); 51 - router.replace(`?${newSearchParams}`, { scroll: false }); 52 - }; 53 - 54 48 return ( 55 49 <Popover> 56 50 <PopoverTrigger asChild> ··· 110 104 selectedValues.add(option.value); 111 105 } 112 106 const filterValues = Array.from(selectedValues); 113 - column?.setFilterValue( 107 + column.setFilterValue( 114 108 filterValues.length ? filterValues : undefined, 115 109 ); 116 - 117 - // update search params 118 - const key = column?.id; 119 - if (key) { 120 - updatePageSearchParams({ 121 - [key]: filterValues?.join(",") || null, 122 - }); 123 - } 110 + setValue(filterValues?.join(",") || null); 124 111 }} 125 112 > 126 113 <div ··· 151 138 <CommandSeparator /> 152 139 <CommandGroup> 153 140 <CommandItem 154 - onSelect={() => column?.setFilterValue(undefined)} 141 + onSelect={() => column.setFilterValue(undefined)} 155 142 className="justify-center text-center" 156 143 > 157 144 Clear filters
+6 -12
apps/web/src/components/data-table/data-table-pagination.tsx
··· 7 7 ChevronsLeft, 8 8 ChevronsRight, 9 9 } from "lucide-react"; 10 - import { useRouter } from "next/navigation"; 11 10 12 11 import { 13 12 Button, ··· 18 17 SelectValue, 19 18 } from "@openstatus/ui"; 20 19 21 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 20 + import { parseAsInteger, useQueryStates } from "nuqs"; 22 21 23 22 // REMINDER: pageIndex pagination issue - jumping back to 0 after change 24 23 ··· 29 28 export function DataTablePagination<TData>({ 30 29 table, 31 30 }: DataTablePaginationProps<TData>) { 32 - const updateSearchParams = useUpdateSearchParams(); 33 - const router = useRouter(); 34 - 35 - const updatePageSearchParams = ( 36 - values: Record<string, number | string | null>, 37 - ) => { 38 - const newSearchParams = updateSearchParams(values); 39 - router.replace(`?${newSearchParams}`, { scroll: false }); 40 - }; 31 + const [{ pageSize }, setSearchParams] = useQueryStates({ 32 + pageSize: parseAsInteger.withDefault(10), 33 + // pageIndex: parseAsInteger.withDefault(0), 34 + }); 41 35 42 36 return ( 43 37 <div className="flex items-center justify-between px-2"> ··· 53 47 value={`${table.getState().pagination.pageSize}`} 54 48 onValueChange={(value) => { 55 49 table.setPageSize(Number(value)); 56 - updatePageSearchParams({ pageSize: value }); 50 + setSearchParams({ pageSize: Number(value) }); 57 51 }} 58 52 > 59 53 <SelectTrigger className="h-8 w-[70px]">
+8 -13
apps/web/src/components/data-table/data-table-toolbar.tsx
··· 2 2 3 3 import type { Table } from "@tanstack/react-table"; 4 4 import { X } from "lucide-react"; 5 - import { useRouter } from "next/navigation"; 5 + import { useRouter, useSearchParams } from "next/navigation"; 6 6 7 7 import { Button } from "@openstatus/ui/src/components/button"; 8 8 import { flyRegionsDict } from "@openstatus/utils"; 9 9 10 10 import { codesDict } from "@/data/code-dictionary"; 11 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 12 11 import { DataTableFacetedFilter } from "./data-table-faceted-filter"; 13 12 import { DataTableFacetedInputDropdown } from "./data-table-faceted-input-dropdown"; 14 13 ··· 20 19 table, 21 20 }: DataTableToolbarProps<TData>) { 22 21 const router = useRouter(); 23 - const updateSearchParams = useUpdateSearchParams(); 22 + const searchParams = useSearchParams(); 24 23 const isFiltered = table.getState().columnFilters.length > 0; 25 24 26 25 return ( ··· 75 74 <Button 76 75 variant="ghost" 77 76 onClick={() => { 77 + const period = searchParams.get("period"); 78 78 table.resetColumnFilters(); 79 - 80 - // reset filter search params (but not period e.g.) 81 - const newSearchParams = updateSearchParams({ 82 - error: null, 83 - statusCode: null, 84 - region: null, 85 - }); 86 - router.replace(`?${newSearchParams}`, { 87 - scroll: false, 88 - }); 79 + if (period) { 80 + router.replace(`?period=${period}`, { 81 + scroll: false, 82 + }); 83 + } 89 84 }} 90 85 className="h-8 px-2 lg:px-3" 91 86 >
+6 -12
apps/web/src/components/data-table/monitor/data-table-pagination.tsx
··· 7 7 ChevronsLeft, 8 8 ChevronsRight, 9 9 } from "lucide-react"; 10 - import { useRouter } from "next/navigation"; 11 10 12 11 import { 13 12 Button, ··· 18 17 SelectValue, 19 18 } from "@openstatus/ui"; 20 19 21 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 20 + import { parseAsInteger, useQueryStates } from "nuqs"; 22 21 23 22 // REMINDER: pageIndex pagination issue - jumping back to 0 after change 24 23 ··· 29 28 export function DataTablePagination<TData>({ 30 29 table, 31 30 }: DataTablePaginationProps<TData>) { 32 - const updateSearchParams = useUpdateSearchParams(); 33 - const router = useRouter(); 34 - 35 - const updatePageSearchParams = ( 36 - values: Record<string, number | string | null>, 37 - ) => { 38 - const newSearchParams = updateSearchParams(values); 39 - router.replace(`?${newSearchParams}`, { scroll: false }); 40 - }; 31 + const [{ pageSize }, setSearchParams] = useQueryStates({ 32 + pageSize: parseAsInteger.withDefault(10), 33 + // pageIndex: parseAsInteger.withDefault(0), 34 + }); 41 35 42 36 return ( 43 37 <div className="flex flex-wrap-reverse items-center justify-between gap-4 px-2"> ··· 54 48 value={`${table.getState().pagination.pageSize}`} 55 49 onValueChange={(value) => { 56 50 table.setPageSize(Number(value)); 57 - updatePageSearchParams({ pageSize: value }); 51 + setSearchParams({ pageSize: Number(value) }); 58 52 }} 59 53 > 60 54 <SelectTrigger className="h-8 w-[70px]">
+1 -5
apps/web/src/components/forms/status-page/form.tsx
··· 21 21 TabsTrigger, 22 22 } from "@/components/dashboard/tabs"; 23 23 import { useDebounce } from "@/hooks/use-debounce"; 24 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 25 24 import { toast, toastAction } from "@/lib/toast"; 26 25 import { slugify } from "@/lib/utils"; 27 26 import { api } from "@/trpc/client"; ··· 80 79 const watchSlug = form.watch("slug"); 81 80 const watchTitle = form.watch("title"); 82 81 const debouncedSlug = useDebounce(watchSlug, 1000); // using debounce to not exhaust the server 83 - const updateSearchParams = useUpdateSearchParams(); 84 82 85 83 const checkUniqueSlug = useCallback(async () => { 86 84 const isUnique = await api.page.getSlugUniqueness.query({ ··· 125 123 if (defaultValues) { 126 124 await api.page.update.mutate(props); 127 125 } else { 128 - const page = await api.page.create.mutate(props); 129 - const id = page?.id || null; 130 - router.replace(`?${updateSearchParams({ id })}`); // to stay on same page and enable 'Advanced' tab 126 + await api.page.create.mutate(props); 131 127 } 132 128 133 129 toast.success("Saved successfully.", {
+8 -10
apps/web/src/components/marketing/pricing/pricing-plan-radio.tsx
··· 7 7 RadioGroup, 8 8 RadioGroupItem, 9 9 } from "@openstatus/ui/src/components/radio-group"; 10 - import { useRouter } from "next/navigation"; 11 10 12 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 13 11 import { cn } from "@/lib/utils"; 12 + import type { WorkspacePlan } from "@openstatus/db/src/schema"; 14 13 15 - export function PricingPlanRadio() { 16 - const updateSearchParams = useUpdateSearchParams(); 17 - const router = useRouter(); 14 + export function PricingPlanRadio({ 15 + onChange, 16 + }: { 17 + onChange(value: WorkspacePlan): void; 18 + }) { 18 19 return ( 19 20 <RadioGroup 20 21 defaultValue="team" 21 22 className="grid grid-cols-4 gap-4" 22 - onValueChange={(value) => { 23 - const searchParams = updateSearchParams({ plan: value }); 24 - router.replace(`?${searchParams}`, { scroll: false }); 25 - }} 23 + onValueChange={onChange} 26 24 > 27 25 {workspacePlans.map((key) => ( 28 26 <div key={key}> ··· 34 32 key === "team" && "bg-muted/50", 35 33 )} 36 34 > 37 - <span className="text-sm capitalize">{key}</span> 35 + <span className="text-sm capitalize">{allPlans[key].title}</span> 38 36 <span className="mt-1 font-light text-muted-foreground text-xs"> 39 37 {allPlans[key].price}€/month 40 38 </span>
+8 -5
apps/web/src/components/marketing/pricing/pricing-wrapper.tsx
··· 4 4 5 5 import type { WorkspacePlan } from "@openstatus/db/src/schema/workspaces/validation"; 6 6 7 + import { workspacePlans } from "@openstatus/db/src/schema"; 8 + import { parseAsStringLiteral, useQueryState } from "nuqs"; 7 9 import { Suspense } from "react"; 8 10 import { PricingPlanRadio } from "./pricing-plan-radio"; 9 11 import { PricingTable } from "./pricing-table"; 10 12 11 13 export function PricingWrapper() { 12 - const searchParams = useSearchParams(); 14 + const [plan, setPlan] = useQueryState( 15 + "plan", 16 + parseAsStringLiteral(workspacePlans).withDefault("team"), 17 + ); 13 18 return ( 14 19 <div> 15 20 <div className="flex flex-col gap-4 sm:hidden"> 16 - <PricingPlanRadio /> 17 - <PricingTable 18 - plans={[(searchParams.get("plan") as WorkspacePlan) || "team"]} 19 - /> 21 + <PricingPlanRadio onChange={setPlan} /> 22 + <PricingTable plans={[plan]} /> 20 23 </div> 21 24 <div className="hidden sm:block"> 22 25 <PricingTable />
+29 -34
apps/web/src/components/monitor-dashboard/region-preset.tsx
··· 1 1 "use client"; 2 2 3 3 import { Check, ChevronsUpDown, Globe2 } from "lucide-react"; 4 - import { usePathname, useRouter } from "next/navigation"; 5 4 import * as React from "react"; 6 5 7 6 import type { Region } from "@openstatus/tinybird"; ··· 26 25 flyRegionsDict, 27 26 } from "@openstatus/utils"; 28 27 29 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 30 28 import { cn } from "@/lib/utils"; 29 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 30 + import { parseAsArrayOf, parseAsStringLiteral, useQueryState } from "nuqs"; 31 31 32 32 interface RegionsPresetProps extends ButtonProps { 33 33 regions: Region[]; ··· 40 40 className, 41 41 ...props 42 42 }: RegionsPresetProps) { 43 - const [selected, setSelected] = React.useState<Region[]>( 44 - selectedRegions.filter((r) => regions.includes(r)), 45 - ); // REMINDER: init without regions that failed to load 46 - const router = useRouter(); 47 - const pathname = usePathname(); 48 - const updateSearchParams = useUpdateSearchParams(); 43 + // TODO: check with the RSC pages 44 + const [selected, setSelected] = useQueryState( 45 + "regions", 46 + parseAsArrayOf(parseAsStringLiteral(flyRegions)) 47 + .withDefault(selectedRegions.filter((r) => regions?.includes(r))) 48 + .withOptions({ 49 + shallow: false, // required for SSR to call the RSC 50 + }), 51 + ); 49 52 50 53 const allSelected = regions.every((r) => selected.includes(r)); 51 54 52 - React.useEffect(() => { 53 - if (!allSelected) { 54 - const searchParams = updateSearchParams({ regions: selected.join(",") }); 55 - router.replace(`${pathname}?${searchParams}`, { scroll: false }); 56 - } else if (allSelected) { 57 - const searchParams = updateSearchParams({ regions: null }); 58 - router.replace(`${pathname}?${searchParams}`, { scroll: false }); 59 - } 60 - }, [allSelected, router, pathname, updateSearchParams, selected]); 55 + const regionsByContinent = regions 56 + .reduce( 57 + (prev, curr) => { 58 + const region = flyRegionsDict[curr]; 61 59 62 - const regionsByContinent = regions.reduce( 63 - (prev, curr) => { 64 - const region = flyRegionsDict[curr]; 60 + const item = prev.find((r) => r.continent === region.continent); 65 61 66 - const item = prev.find((r) => r.continent === region.continent); 62 + if (item) { 63 + item.data.push(region); 64 + } else { 65 + prev.push({ 66 + continent: region.continent, 67 + data: [region], 68 + }); 69 + } 67 70 68 - if (item) { 69 - item.data.push(region); 70 - } else { 71 - prev.push({ 72 - continent: region.continent, 73 - data: [region], 74 - }); 75 - } 76 - 77 - return prev; 78 - }, 79 - [] as { continent: Continent; data: RegionInfo[] }[], 80 - ); 71 + return prev; 72 + }, 73 + [] as { continent: Continent; data: RegionInfo[] }[], 74 + ) 75 + .sort((a, b) => a.continent.localeCompare(b.continent)); 81 76 82 77 return ( 83 78 <Popover>
+5 -12
apps/web/src/components/monitor-dashboard/search-params-preset.tsx
··· 7 7 SelectTrigger, 8 8 SelectValue, 9 9 } from "@openstatus/ui/src/components/select"; 10 - import { usePathname, useRouter } from "next/navigation"; 11 10 import type { ReactNode } from "react"; 12 11 13 12 import { Icons } from "@/components/icons"; 14 13 import type { ValidIcon } from "@/components/icons"; 15 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 16 14 import { cn } from "@/lib/utils"; 15 + import { useQueryState } from "nuqs"; 17 16 18 17 export function SearchParamsPreset<T extends string>({ 19 18 disabled, ··· 27 26 }: { 28 27 disabled?: boolean; 29 28 defaultValue?: T; 30 - values: readonly T[]; 29 + values: readonly T[] | T[]; 31 30 searchParam: string; 32 31 icon?: ValidIcon; 33 32 placeholder?: string; 34 33 formatter?(value: T): ReactNode; 35 34 className?: string; 36 35 }) { 37 - const router = useRouter(); 38 - const pathname = usePathname(); 39 - const updateSearchParams = useUpdateSearchParams(); 40 - 41 - function onSelect(value: T) { 42 - const searchParams = updateSearchParams({ [searchParam]: value }); 43 - router.replace(`${pathname}?${searchParams}`, { scroll: false }); 44 - } 36 + const [value, setValue] = useQueryState(searchParam, { shallow: false }); 45 37 46 38 const Icon = icon ? Icons[icon] : undefined; 47 39 48 40 return ( 49 41 <Select 50 42 defaultValue={defaultValue} 51 - onValueChange={onSelect} 43 + value={value || defaultValue} 44 + onValueChange={setValue} 52 45 disabled={disabled} 53 46 > 54 47 <SelectTrigger
-59
apps/web/src/components/ping-response-analysis/select-region.tsx
··· 1 - "use client"; 2 - 3 - import { usePathname, useRouter } from "next/navigation"; 4 - 5 - import type { MonitorFlyRegion } from "@openstatus/db/src/schema/constants"; 6 - import { flyRegions } from "@openstatus/db/src/schema/constants"; 7 - import { Label } from "@openstatus/ui/src/components/label"; 8 - import { 9 - Select, 10 - SelectContent, 11 - SelectItem, 12 - SelectTrigger, 13 - SelectValue, 14 - } from "@openstatus/ui/src/components/select"; 15 - 16 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 17 - import { regionFormatter } from "./utils"; 18 - 19 - export function SelectRegion({ 20 - defaultValue, 21 - }: { 22 - defaultValue?: MonitorFlyRegion; 23 - }) { 24 - const router = useRouter(); 25 - const pathname = usePathname(); 26 - const updateSearchParams = useUpdateSearchParams(); 27 - 28 - function onSelect(value: string) { 29 - const searchParams = updateSearchParams({ region: value }); 30 - router.replace(`${pathname}?${searchParams}`, { scroll: false }); 31 - } 32 - 33 - return ( 34 - <div className="grid gap-1.5"> 35 - <Label htmlFor="region">Region</Label> 36 - <Select onValueChange={onSelect} defaultValue={defaultValue}> 37 - <SelectTrigger 38 - id="region" 39 - name="region" 40 - className="w-full sm:w-[240px]" 41 - > 42 - <span className="flex items-center gap-2"> 43 - <SelectValue /> 44 - </span> 45 - </SelectTrigger> 46 - <SelectContent> 47 - {flyRegions.map((region) => ( 48 - <SelectItem key={region} value={region}> 49 - {regionFormatter(region, "long")} 50 - </SelectItem> 51 - ))} 52 - </SelectContent> 53 - </Select> 54 - <p className="text-muted-foreground text-sm"> 55 - Select a region to inspect closer 56 - </p> 57 - </div> 58 - ); 59 - }
+4
apps/web/src/hooks/use-update-search-params.ts
··· 1 1 import { useSearchParams } from "next/navigation"; 2 2 import * as React from "react"; 3 3 4 + /** 5 + * 6 + * @deprecated use `nuqs` instead 7 + */ 4 8 export default function useUpdateSearchParams() { 5 9 const searchParams = useSearchParams(); 6 10
+18
pnpm-lock.yaml
··· 401 401 next-themes: 402 402 specifier: 0.2.1 403 403 version: 0.2.1(next@14.2.4(@opentelemetry/api@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 404 + nuqs: 405 + specifier: 1.19.1 406 + version: 1.19.1(next@14.2.4(@opentelemetry/api@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) 404 407 posthog-js: 405 408 specifier: 1.136.1 406 409 version: 1.136.1 ··· 8234 8237 resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} 8235 8238 engines: {node: '>= 8'} 8236 8239 8240 + mitt@3.0.1: 8241 + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} 8242 + 8237 8243 mkdirp-classic@0.5.3: 8238 8244 resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} 8239 8245 ··· 8475 8481 npm-run-path@5.3.0: 8476 8482 resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} 8477 8483 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 8484 + 8485 + nuqs@1.19.1: 8486 + resolution: {integrity: sha512-oixldNThB1wbu6B5K961++7wpTz/EZFPWnraGmIQhibDT+YxRJNplWMIoPJgL4dlsiSDVI5bbUWKpzsIWVh3Pg==} 8487 + peerDependencies: 8488 + next: '>=13.4 <14.0.2 || ^14.0.3' 8478 8489 8479 8490 nypm@0.3.9: 8480 8491 resolution: {integrity: sha512-BI2SdqqTHg2d4wJh8P9A1W+bslg33vOE9IZDY6eR2QC+Pu1iNBVZUqczrd43rJb+fMzHU7ltAYKsEFY/kHMFcw==} ··· 19222 19233 minipass: 3.3.6 19223 19234 yallist: 4.0.0 19224 19235 19236 + mitt@3.0.1: {} 19237 + 19225 19238 mkdirp-classic@0.5.3: {} 19226 19239 19227 19240 mkdirp@0.5.6: ··· 19501 19514 npm-run-path@5.3.0: 19502 19515 dependencies: 19503 19516 path-key: 4.0.0 19517 + 19518 + nuqs@1.19.1(next@14.2.4(@opentelemetry/api@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): 19519 + dependencies: 19520 + mitt: 3.0.1 19521 + next: 14.2.4(@opentelemetry/api@1.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 19504 19522 19505 19523 nypm@0.3.9: 19506 19524 dependencies: