From 95ec79b98f0dbe2d27ff2bb0d435674441403639 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 17 Apr 2025 23:18:33 +1000 Subject: [PATCH] feat: Infinite query hook block (#34650) * infinite list * infinite list block * registration * add missing supportedFrameworks * Add tables to the supabase project. Generate the types for it. * Refactor the infinite list query to be just a hook. * Clean up the block. Add comments. * Regenerate the registry. * Fix the docs, the block is not framework-dependent. * Set the package versions to * to be defined by other packages. * Minor fixes to the block. * Fix the examples. * Fix the docs for the new hook. * Fix the demo. * Add more migrations to the db. * Fix various issues with the query. Rewrote it to useSyncExternalStore. * Fix the SSR for the hook. * More fixes. * Try initializing the store in a useEffect. * Fix the pnpm-lock file. * Minor fixes in the docs. * Put the infinite list under a reusable components section. * Update apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts * Change the example DB to use todos. * Update the docs to be about Todos quickstart. * List edits * Fix link * Regenerate the registry. * Add query hook to the landing page. --------- Co-authored-by: Ivan Vasilov Co-authored-by: Terry Sutton --- apps/ui-library/README.md | 8 + apps/ui-library/__registry__/index.tsx | 24 ++ apps/ui-library/app/(app)/page.tsx | 18 ++ .../app/example/infinite-list-demo/layout.tsx | 18 ++ .../app/example/infinite-list-demo/page.tsx | 5 + apps/ui-library/config/docs.ts | 8 + .../content/docs/infinite-query-hook.mdx | 290 ++++++++++++++++++ apps/ui-library/lib/process-registry.ts | 1 + apps/ui-library/package.json | 14 +- .../public/r/infinite-query-hook.json | 19 ++ apps/ui-library/registry.json | 15 + apps/ui-library/registry/blocks.ts | 15 +- .../hooks/use-infinite-query.ts | 210 +++++++++++++ .../infinite-query-hook/registry-item.json | 15 + .../default/components/ui/checkbox.tsx | 28 ++ .../default/components/ui/skeleton.tsx | 7 + .../examples/infinite-query-hook-demo.tsx | 149 +++++++++ .../default/fixtures/database.types.ts | 179 +++++++++++ .../default/fixtures/lib/supabase/client.ts | 9 + apps/ui-library/registry/examples.ts | 11 + .../migrations/20250417083412_addTodos.sql | 99 ++++++ apps/ui-library/supabase/seed.sql | 239 +++++++++++++++ pnpm-lock.yaml | 18 +- 23 files changed, 1381 insertions(+), 18 deletions(-) create mode 100644 apps/ui-library/app/example/infinite-list-demo/layout.tsx create mode 100644 apps/ui-library/app/example/infinite-list-demo/page.tsx create mode 100644 apps/ui-library/content/docs/infinite-query-hook.mdx create mode 100644 apps/ui-library/public/r/infinite-query-hook.json create mode 100644 apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts create mode 100644 apps/ui-library/registry/default/blocks/infinite-query-hook/registry-item.json create mode 100644 apps/ui-library/registry/default/components/ui/checkbox.tsx create mode 100644 apps/ui-library/registry/default/components/ui/skeleton.tsx create mode 100644 apps/ui-library/registry/default/examples/infinite-query-hook-demo.tsx create mode 100644 apps/ui-library/registry/default/fixtures/database.types.ts create mode 100644 apps/ui-library/registry/default/fixtures/lib/supabase/client.ts create mode 100644 apps/ui-library/supabase/migrations/20250417083412_addTodos.sql create mode 100644 apps/ui-library/supabase/seed.sql diff --git a/apps/ui-library/README.md b/apps/ui-library/README.md index c4033664f8..2d77ceb73f 100644 --- a/apps/ui-library/README.md +++ b/apps/ui-library/README.md @@ -34,3 +34,11 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. + +## Supabase types + +To regenerate the Supabase database types, run + +``` +supabase gen types --local > registry/default/fixtures/database.types.ts +``` diff --git a/apps/ui-library/__registry__/index.tsx b/apps/ui-library/__registry__/index.tsx index 609d82bb2c..28f3f0c29f 100644 --- a/apps/ui-library/__registry__/index.tsx +++ b/apps/ui-library/__registry__/index.tsx @@ -343,6 +343,18 @@ export const Index: Record = { chunks: [] } , + "infinite-query-hook": { + name: "infinite-query-hook", + type: "registry:block", + registryDependencies: [], + + source: "", + files: ["registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts"], + category: "undefined", + subcategory: "undefined", + chunks: [] + } + , "supabase-client-nextjs": { name: "supabase-client-nextjs", type: "registry:lib", @@ -486,6 +498,18 @@ export const Index: Record = { subcategory: "undefined", chunks: [] } + , + "infinite-query-hook-demo": { + name: "infinite-query-hook-demo", + type: "registry:example", + registryDependencies: [], + component: React.lazy(() => import("@/registry/default/examples/infinite-query-hook-demo.tsx")), + source: "", + files: ["registry/default/examples/infinite-query-hook-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + } }, } diff --git a/apps/ui-library/app/(app)/page.tsx b/apps/ui-library/app/(app)/page.tsx index 68aad26adb..129b8b3352 100644 --- a/apps/ui-library/app/(app)/page.tsx +++ b/apps/ui-library/app/(app)/page.tsx @@ -178,6 +178,24 @@ export default function Home() { + + {/* Infinite Query Hook */} +
+ Infinite Query Hook + + Go to block ➔ + +
+ +
+
+ +
+
+ diff --git a/apps/ui-library/app/example/infinite-list-demo/layout.tsx b/apps/ui-library/app/example/infinite-list-demo/layout.tsx new file mode 100644 index 0000000000..11935f8d25 --- /dev/null +++ b/apps/ui-library/app/example/infinite-list-demo/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { BaseInjector } from './../base-injector' + +export const metadata: Metadata = { + title: 'Infinite Data Table Demo', + description: 'Demonstration of the Infinite Data Table component.', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <> + +
+
{children}
+
+ + ) +} diff --git a/apps/ui-library/app/example/infinite-list-demo/page.tsx b/apps/ui-library/app/example/infinite-list-demo/page.tsx new file mode 100644 index 0000000000..faae70bf60 --- /dev/null +++ b/apps/ui-library/app/example/infinite-list-demo/page.tsx @@ -0,0 +1,5 @@ +import InfiniteQueryHookDemo from '@/registry/default/examples/infinite-query-hook-demo' + +export default function Page() { + return +} diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts index 5db1de8a8a..55bfa3b7bb 100644 --- a/apps/ui-library/config/docs.ts +++ b/apps/ui-library/config/docs.ts @@ -97,6 +97,14 @@ export const componentPages: SidebarNavGroup = { items: [], commandItemLabel: 'Realtime Chat', }, + { + title: 'Infinite Query Hook', + supportedFrameworks: [], + href: '/docs/infinite-query-hook', + new: true, + items: [], + commandItemLabel: 'Infinite Query Hook', + }, ], } diff --git a/apps/ui-library/content/docs/infinite-query-hook.mdx b/apps/ui-library/content/docs/infinite-query-hook.mdx new file mode 100644 index 0000000000..579c1429e9 --- /dev/null +++ b/apps/ui-library/content/docs/infinite-query-hook.mdx @@ -0,0 +1,290 @@ +--- +title: Infinite Query Hook +description: React hook for infinite lists, fetching data from Supabase. +--- + + + +## Installation + + + +## Folder structure + + + +## Introduction + +The Infinite Query Hook provides a single React hook which will make it easier to load data progressively from your Supabase database. It handles data fetching and pagination state, It is meant to be used with infinite lists or tables. +The hook is fully typed, provided you have generated and setup your database types. + +## Adding types + +Before using this hook, we **highly** recommend you setup database types in your project. This will make the hook fully-typesafe. More info about generating Typescript types from database schema [here](https://supabase.com/docs/guides/api/rest/generating-types) + +## Props + +| Prop | Type | Description | +| --------------- | --------------------------------------------------------- | ---------------------------------------------------------------- | +| `tableName` | `string` | **Required.** The name of the Supabase table to fetch data from. | +| `columns` | `string` | Columns to select from the table. Defaults to `'*'`. | +| `pageSize` | `number` | Number of items to fetch per page. Defaults to `20`. | +| `trailingQuery` | `(query: SupabaseSelectBuilder) => SupabaseSelectBuilder` | Function to apply filters or sorting to the Supabase query. | + +## Return type + +data, count, isSuccess, isLoading, isFetching, error, hasMore, fetchNextPage + +| Prop | Type | Description | +| --------------- | ------------- | ----------------------------------------------------------------------------------- | +| `data` | `TableData[]` | An array of fetched items. | +| `count` | `number` | Number of total items in the database. It takes `trailingQuery` into consideration. | +| `isSuccess` | `boolean` | It's true if the last API call succeeded. | +| `isLoading` | `boolean` | It's true only for the initial fetch. | +| `isFetching` | `boolean` | It's true for the initial and all incremental fetches. | +| `error` | `any` | The error from the last fetch. | +| `hasMore` | `boolean` | Whether the query has finished fetching all items from the database | +| `fetchNextPage` | `() => void` | Sends a new request for the next items | + +## Type safety + +The hook will use the typed defined on your Supabase client if they're setup ([more info](https://supabase.com/docs/reference/javascript/typescript-support)). + +The hook also supports an custom defined result type by using `useInfiniteQuery`. For example, if you have a custom type for `Product`, you can use it like this `useInfiniteQuery`. + +## Usage + +### With sorting + +```tsx +const { data, fetchNextPage } = useInfiniteQuery({ + tableName: 'products', + columns: '*', + pageSize: 10, + trailingQuery: (query) => query.order('created_at', { ascending: false }), +}) + +return ( +
+ {data.map((item) => ( + + ))} + +
+) +``` + +### With filtering on search params + +This example will filter based on a search param like `example.com/?q=hello`. + +```tsx +const params = useSearchParams() +const searchQuery = params.get('q') + +const { data, isLoading, isFetching, fetchNextPage, count, isSuccess } = useInfiniteQuery({ + tableName: 'products', + columns: '*', + pageSize: 10, + trailingQuery: (query) => { + if (searchQuery && searchQuery.length > 0) { + query = query.ilike('name', `%${searchQuery}%`) + } + return query + }, +}) + +return ( +
+ {data.map((item) => ( + + ))} + +
+) +``` + +## Reusable components + +### Infinite list (fetches as you scroll) + +The following component abstracts the hook into a component. It includes few utility components for no results and end of the list. + +```tsx +'use client' + +import { cn } from '@/lib/utils' +import { + SupabaseQueryHandler, + SupabaseTableData, + SupabaseTableName, + useInfiniteQuery, +} from '@/hooks/use-infinite-query' +import * as React from 'react' + +interface InfiniteListProps { + tableName: TableName + columns?: string + pageSize?: number + trailingQuery?: SupabaseQueryHandler + renderItem: (item: SupabaseTableData, index: number) => React.ReactNode + className?: string + renderNoResults?: () => React.ReactNode + renderEndMessage?: () => React.ReactNode + renderSkeleton?: (count: number) => React.ReactNode +} + +const DefaultNoResults = () => ( +
No results.
+) + +const DefaultEndMessage = () => ( +
You've reached the end.
+) + +const defaultSkeleton = (count: number) => ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+ ))} +
+) + +export function InfiniteList({ + tableName, + columns = '*', + pageSize = 20, + trailingQuery, + renderItem, + className, + renderNoResults = DefaultNoResults, + renderEndMessage = DefaultEndMessage, + renderSkeleton = defaultSkeleton, +}: InfiniteListProps) { + const { data, isFetching, hasMore, fetchNextPage, isSuccess } = useInfiniteQuery({ + tableName, + columns, + pageSize, + trailingQuery, + }) + + // Ref for the scrolling container + const scrollContainerRef = React.useRef(null) + + // Intersection observer logic - target the last rendered *item* or a dedicated sentinel + const loadMoreSentinelRef = React.useRef(null) + const observer = React.useRef(null) + + React.useEffect(() => { + if (observer.current) observer.current.disconnect() + + observer.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isFetching) { + fetchNextPage() + } + }, + { + root: scrollContainerRef.current, // Use the scroll container for scroll detection + threshold: 0.1, // Trigger when 10% of the target is visible + rootMargin: '0px 0px 100px 0px', // Trigger loading a bit before reaching the end + } + ) + + if (loadMoreSentinelRef.current) { + observer.current.observe(loadMoreSentinelRef.current) + } + + return () => { + if (observer.current) observer.current.disconnect() + } + }, [isFetching, hasMore, fetchNextPage]) + + return ( +
+
+ {isSuccess && data.length === 0 && renderNoResults()} + + {data.map((item, index) => renderItem(item, index))} + + {isFetching && renderSkeleton && renderSkeleton(pageSize)} + +
+ + {!hasMore && data.length > 0 && renderEndMessage()} +
+
+ ) +} +``` + +Use the `InfiniteList` component with the [Todo List](https://supabase.com/dashboard/project/_/sql/quickstarts) quickstart. + +Add `` to a page to see it in action. +Ensure the [Checkbox](https://ui.shadcn.com/docs/components/checkbox) component from shadcn/ui is installed, and [regenerate/download](https://supabase.com/docs/guides/api/rest/generating-types) types after running the quickstart. + +```tsx +'use client' + +import { Checkbox } from '@/components/ui/checkbox' +import { InfiniteList } from './infinite-component' +import { SupabaseQueryHandler } from '@/hooks/use-infinite-query' +import { Database } from '@/lib/supabase.types' + +type TodoTask = Database['public']['Tables']['todos']['Row'] + +// Define how each item should be rendered +const renderTodoItem = (todo: TodoTask) => { + return ( +
+
+ +
+ {todo.task} +
+ {new Date(todo.inserted_at).toLocaleDateString()} +
+
+
+
+ ) +} + +const orderByInsertedAt: SupabaseQueryHandler<'todos'> = (query) => { + return query.order('inserted_at', { ascending: false }) +} + +export const InfiniteListDemo = () => { + return ( +
+ +
+ ) +} +``` + + + The Todo List table has Row Level Security (RLS) enabled by default. Feel free disable it + temporarily while testing. With RLS enabled, you will get an [empty + array](https://supabase.com/docs/guides/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx) + of results by default. [Read + more](https://supabase.com/docs/guides/database/postgres/row-level-security) about RLS. + + +## Further reading + +- [Generating Typescript types from the database](https://supabase.com/docs/reference/javascript/typescript-support) +- [Supabase Database API](https://supabase.com/docs/reference/javascript/select) +- [Supabase Pagination](https://supabase.com/docs/reference/javascript/select#pagination) +- [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) diff --git a/apps/ui-library/lib/process-registry.ts b/apps/ui-library/lib/process-registry.ts index e3b9cd7877..3d8bd3d04b 100644 --- a/apps/ui-library/lib/process-registry.ts +++ b/apps/ui-library/lib/process-registry.ts @@ -46,6 +46,7 @@ export function generateRegistryTree(registryPath: string): RegistryNode[] { // Remove any paths in the file content that point to the block directory. const content = file.content .replaceAll(/@\/registry\/default\/blocks\/.+?\//gi, '@/') + .replaceAll(/@\/registry\/default\/fixtures\//gi, '@/') .replaceAll(/@\/registry\/default\//gi, '@/') .replaceAll(/@\/clients\/.+?\//gi, '@/') diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index eed39497b0..8157c99e4e 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -17,14 +17,16 @@ "typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@radix-ui/react-avatar": "^1.0.4", - "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-progress": "^1.0.3", - "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-tooltip": "^1.0.7", + "@radix-ui/react-avatar": "*", + "@radix-ui/react-checkbox": "*", + "@radix-ui/react-label": "*", + "@radix-ui/react-progress": "*", + "@radix-ui/react-slot": "*", + "@radix-ui/react-tooltip": "*", "@react-router/fs-routes": "^7.4.0", + "@supabase/postgrest-js": "*", "@supabase/supa-mdx-lint": "0.2.6-alpha", - "class-variance-authority": "^0.6.0", + "class-variance-authority": "*", "common": "workspace:*", "contentlayer2": "0.4.6", "eslint-config-supabase": "workspace:*", diff --git a/apps/ui-library/public/r/infinite-query-hook.json b/apps/ui-library/public/r/infinite-query-hook.json new file mode 100644 index 0000000000..ca7a0e25c1 --- /dev/null +++ b/apps/ui-library/public/r/infinite-query-hook.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "infinite-query-hook", + "type": "registry:block", + "title": "Infinite Query Hook", + "description": "React hook for infinite lists, fetching data from Supabase.", + "dependencies": [ + "@supabase/supabase-js", + "@supabase/postgrest-js@*" + ], + "registryDependencies": [], + "files": [ + { + "path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts", + "content": "'use client'\n\nimport { createClient } from '@/registry/default/fixtures/lib/supabase/client'\nimport { PostgrestQueryBuilder } from '@supabase/postgrest-js'\nimport { SupabaseClient } from '@supabase/supabase-js'\nimport { useEffect, useRef, useSyncExternalStore } from 'react'\n\nconst supabase = createClient()\n\n// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.\ntype SupabaseClientType = typeof supabase\n\n// Utility type to check if the type is any\ntype IfAny = 0 extends 1 & T ? Y : N\n\n// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.\ntype Database =\n SupabaseClientType extends SupabaseClient\n ? IfAny<\n U,\n {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n },\n U\n >\n : never\n\n// Change this to the database schema you want to use\ntype DatabaseSchema = Database['public']\n\n// Extracts the table names from the database type\ntype SupabaseTableName = keyof DatabaseSchema['Tables']\n\n// Extracts the table definition from the database type\ntype SupabaseTableData = DatabaseSchema['Tables'][T]['Row']\n\ntype SupabaseSelectBuilder = ReturnType<\n PostgrestQueryBuilder['select']\n>\n\n// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\ntype SupabaseQueryHandler = (\n query: SupabaseSelectBuilder\n) => SupabaseSelectBuilder\n\ninterface UseInfiniteQueryProps {\n // The table name to query\n tableName: T\n // The columns to select, defaults to `*`\n columns?: string\n // The number of items to fetch per page, defaults to `20`\n pageSize?: number\n // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\n trailingQuery?: SupabaseQueryHandler\n}\n\ninterface StoreState {\n data: TData[]\n count: number\n isSuccess: boolean\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n hasInitialFetch: boolean\n}\n\ntype Listener = () => void\n\nfunction createStore, T extends SupabaseTableName>(\n props: UseInfiniteQueryProps\n) {\n const { tableName, columns = '*', pageSize = 20, trailingQuery } = props\n\n let state: StoreState = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n }\n\n const listeners = new Set()\n\n const notify = () => {\n listeners.forEach((listener) => listener())\n }\n\n const setState = (newState: Partial>) => {\n state = { ...state, ...newState }\n notify()\n }\n\n const fetchPage = async (skip: number) => {\n if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return\n\n setState({ isFetching: true })\n\n let query = supabase\n .from(tableName)\n .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder\n\n if (trailingQuery) {\n query = trailingQuery(query)\n }\n const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)\n\n if (error) {\n console.error('An unexpected error occurred:', error)\n setState({ error })\n } else {\n const deduplicatedData = ((newData || []) as TData[]).filter(\n (item) => !state.data.find((old) => old.id === item.id)\n )\n\n setState({\n data: [...state.data, ...deduplicatedData],\n count: count || 0,\n isSuccess: true,\n error: null,\n })\n }\n setState({ isFetching: false })\n }\n\n const fetchNextPage = async () => {\n if (state.isFetching) return\n await fetchPage(state.data.length)\n }\n\n const initialize = async () => {\n setState({ isLoading: true, isSuccess: false, data: [] })\n await fetchNextPage()\n setState({ isLoading: false, hasInitialFetch: true })\n }\n\n return {\n getState: () => state,\n subscribe: (listener: Listener) => {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n fetchNextPage,\n initialize,\n }\n}\n\n// Empty initial state to avoid hydration errors.\nconst initialState: any = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n}\n\nfunction useInfiniteQuery<\n TData extends SupabaseTableData,\n T extends SupabaseTableName = SupabaseTableName,\n>(props: UseInfiniteQueryProps) {\n const storeRef = useRef(createStore(props))\n\n const state = useSyncExternalStore(\n storeRef.current.subscribe,\n () => storeRef.current.getState(),\n () => initialState as StoreState\n )\n\n useEffect(() => {\n // Recreate store if props change\n if (\n storeRef.current.getState().hasInitialFetch &&\n (props.tableName !== props.tableName ||\n props.columns !== props.columns ||\n props.pageSize !== props.pageSize)\n ) {\n storeRef.current = createStore(props)\n }\n\n if (!state.hasInitialFetch && typeof window !== 'undefined') {\n storeRef.current.initialize()\n }\n }, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch])\n\n return {\n data: state.data,\n count: state.count,\n isSuccess: state.isSuccess,\n isLoading: state.isLoading,\n isFetching: state.isFetching,\n error: state.error,\n hasMore: state.count > state.data.length,\n fetchNextPage: storeRef.current.fetchNextPage,\n }\n}\n\nexport {\n useInfiniteQuery,\n type SupabaseQueryHandler,\n type SupabaseTableData,\n type SupabaseTableName,\n type UseInfiniteQueryProps,\n}\n", + "type": "registry:hook" + } + ] +} \ No newline at end of file diff --git a/apps/ui-library/registry.json b/apps/ui-library/registry.json index 73f6f8a96c..22379b2828 100644 --- a/apps/ui-library/registry.json +++ b/apps/ui-library/registry.json @@ -1115,6 +1115,21 @@ } ] }, + { + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "infinite-query-hook", + "type": "registry:block", + "title": "Infinite Query Hook", + "description": "React hook for infinite lists, fetching data from Supabase.", + "dependencies": ["@supabase/supabase-js", "@supabase/postgrest-js@*"], + "registryDependencies": [], + "files": [ + { + "path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts", + "type": "registry:hook" + } + ] + }, { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "supabase-client-nextjs", diff --git a/apps/ui-library/registry/blocks.ts b/apps/ui-library/registry/blocks.ts index 08f6c60da4..7a1b15a4f6 100644 --- a/apps/ui-library/registry/blocks.ts +++ b/apps/ui-library/registry/blocks.ts @@ -1,7 +1,14 @@ import { type Registry, type RegistryItem } from 'shadcn/registry' import { clients } from './clients' +import { registryItemAppend } from './utils' + import currentUserAvatar from './default/blocks/current-user-avatar/registry-item.json' with { type: 'json' } import dropzone from './default/blocks/dropzone/registry-item.json' with { type: 'json' } +import infiniteQueryHook from './default/blocks/infinite-query-hook/registry-item.json' with { type: 'json' } +import realtimeAvatarStack from './default/blocks/realtime-avatar-stack/registry-item.json' with { type: 'json' } +import realtimeChat from './default/blocks/realtime-chat/registry-item.json' with { type: 'json' } +import realtimeCursor from './default/blocks/realtime-cursor/registry-item.json' with { type: 'json' } + import passwordBasedAuthNextjs from './default/blocks/password-based-auth-nextjs/registry-item.json' with { type: 'json' } import passwordBasedAuthReactRouter from './default/blocks/password-based-auth-react-router/registry-item.json' with { type: 'json' } import passwordBasedAuthReact from './default/blocks/password-based-auth-react/registry-item.json' with { type: 'json' } @@ -12,12 +19,6 @@ import socialAuthReactRouter from './default/blocks/social-auth-react-router/reg import socialAuthReact from './default/blocks/social-auth-react/registry-item.json' with { type: 'json' } import socialAuthTanstack from './default/blocks/social-auth-tanstack/registry-item.json' with { type: 'json' } -import realtimeAvatarStack from './default/blocks/realtime-avatar-stack/registry-item.json' with { type: 'json' } - -import realtimeChat from './default/blocks/realtime-chat/registry-item.json' with { type: 'json' } -import realtimeCursor from './default/blocks/realtime-cursor/registry-item.json' with { type: 'json' } -import { registryItemAppend } from './utils' - const combine = (component: Registry['items'][number]) => { return clients.flatMap((client) => { return registryItemAppend( @@ -51,4 +52,6 @@ export const blocks = [ ...combine(currentUserAvatar as RegistryItem), ...combine(realtimeAvatarStack as RegistryItem), ...combine(realtimeChat as RegistryItem), + // infinite query hook is intentionally not combined with the clients since it depends on clients having database types. + infiniteQueryHook as RegistryItem, ] as Registry['items'] diff --git a/apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts b/apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts new file mode 100644 index 0000000000..685b207407 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts @@ -0,0 +1,210 @@ +'use client' + +import { createClient } from '@/registry/default/fixtures/lib/supabase/client' +import { PostgrestQueryBuilder } from '@supabase/postgrest-js' +import { SupabaseClient } from '@supabase/supabase-js' +import { useEffect, useRef, useSyncExternalStore } from 'react' + +const supabase = createClient() + +// The following types are used to make the hook type-safe. It extracts the database type from the supabase client. +type SupabaseClientType = typeof supabase + +// Utility type to check if the type is any +type IfAny = 0 extends 1 & T ? Y : N + +// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly. +type Database = + SupabaseClientType extends SupabaseClient + ? IfAny< + U, + { + public: { + Tables: Record + Views: Record + Functions: Record + } + }, + U + > + : never + +// Change this to the database schema you want to use +type DatabaseSchema = Database['public'] + +// Extracts the table names from the database type +type SupabaseTableName = keyof DatabaseSchema['Tables'] + +// Extracts the table definition from the database type +type SupabaseTableData = DatabaseSchema['Tables'][T]['Row'] + +type SupabaseSelectBuilder = ReturnType< + PostgrestQueryBuilder['select'] +> + +// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten. +type SupabaseQueryHandler = ( + query: SupabaseSelectBuilder +) => SupabaseSelectBuilder + +interface UseInfiniteQueryProps { + // The table name to query + tableName: T + // The columns to select, defaults to `*` + columns?: string + // The number of items to fetch per page, defaults to `20` + pageSize?: number + // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten. + trailingQuery?: SupabaseQueryHandler +} + +interface StoreState { + data: TData[] + count: number + isSuccess: boolean + isLoading: boolean + isFetching: boolean + error: Error | null + hasInitialFetch: boolean +} + +type Listener = () => void + +function createStore, T extends SupabaseTableName>( + props: UseInfiniteQueryProps +) { + const { tableName, columns = '*', pageSize = 20, trailingQuery } = props + + let state: StoreState = { + data: [], + count: 0, + isSuccess: false, + isLoading: false, + isFetching: false, + error: null, + hasInitialFetch: false, + } + + const listeners = new Set() + + const notify = () => { + listeners.forEach((listener) => listener()) + } + + const setState = (newState: Partial>) => { + state = { ...state, ...newState } + notify() + } + + const fetchPage = async (skip: number) => { + if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return + + setState({ isFetching: true }) + + let query = supabase + .from(tableName) + .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder + + if (trailingQuery) { + query = trailingQuery(query) + } + const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1) + + if (error) { + console.error('An unexpected error occurred:', error) + setState({ error }) + } else { + const deduplicatedData = ((newData || []) as TData[]).filter( + (item) => !state.data.find((old) => old.id === item.id) + ) + + setState({ + data: [...state.data, ...deduplicatedData], + count: count || 0, + isSuccess: true, + error: null, + }) + } + setState({ isFetching: false }) + } + + const fetchNextPage = async () => { + if (state.isFetching) return + await fetchPage(state.data.length) + } + + const initialize = async () => { + setState({ isLoading: true, isSuccess: false, data: [] }) + await fetchNextPage() + setState({ isLoading: false, hasInitialFetch: true }) + } + + return { + getState: () => state, + subscribe: (listener: Listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + fetchNextPage, + initialize, + } +} + +// Empty initial state to avoid hydration errors. +const initialState: any = { + data: [], + count: 0, + isSuccess: false, + isLoading: false, + isFetching: false, + error: null, + hasInitialFetch: false, +} + +function useInfiniteQuery< + TData extends SupabaseTableData, + T extends SupabaseTableName = SupabaseTableName, +>(props: UseInfiniteQueryProps) { + const storeRef = useRef(createStore(props)) + + const state = useSyncExternalStore( + storeRef.current.subscribe, + () => storeRef.current.getState(), + () => initialState as StoreState + ) + + useEffect(() => { + // Recreate store if props change + if ( + storeRef.current.getState().hasInitialFetch && + (props.tableName !== props.tableName || + props.columns !== props.columns || + props.pageSize !== props.pageSize) + ) { + storeRef.current = createStore(props) + } + + if (!state.hasInitialFetch && typeof window !== 'undefined') { + storeRef.current.initialize() + } + }, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch]) + + return { + data: state.data, + count: state.count, + isSuccess: state.isSuccess, + isLoading: state.isLoading, + isFetching: state.isFetching, + error: state.error, + hasMore: state.count > state.data.length, + fetchNextPage: storeRef.current.fetchNextPage, + } +} + +export { + useInfiniteQuery, + type SupabaseQueryHandler, + type SupabaseTableData, + type SupabaseTableName, + type UseInfiniteQueryProps, +} diff --git a/apps/ui-library/registry/default/blocks/infinite-query-hook/registry-item.json b/apps/ui-library/registry/default/blocks/infinite-query-hook/registry-item.json new file mode 100644 index 0000000000..faccbfee59 --- /dev/null +++ b/apps/ui-library/registry/default/blocks/infinite-query-hook/registry-item.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "infinite-query-hook", + "type": "registry:block", + "title": "Infinite Query Hook", + "description": "React hook for infinite lists, fetching data from Supabase.", + "dependencies": ["@supabase/supabase-js", "@supabase/postgrest-js@*"], + "registryDependencies": [], + "files": [ + { + "path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts", + "type": "registry:hook" + } + ] +} diff --git a/apps/ui-library/registry/default/components/ui/checkbox.tsx b/apps/ui-library/registry/default/components/ui/checkbox.tsx new file mode 100644 index 0000000000..139e44ebda --- /dev/null +++ b/apps/ui-library/registry/default/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +'use client' + +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/apps/ui-library/registry/default/components/ui/skeleton.tsx b/apps/ui-library/registry/default/components/ui/skeleton.tsx new file mode 100644 index 0000000000..00ca2da6c0 --- /dev/null +++ b/apps/ui-library/registry/default/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils' + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
+} + +export { Skeleton } diff --git a/apps/ui-library/registry/default/examples/infinite-query-hook-demo.tsx b/apps/ui-library/registry/default/examples/infinite-query-hook-demo.tsx new file mode 100644 index 0000000000..3d3cfd6b1c --- /dev/null +++ b/apps/ui-library/registry/default/examples/infinite-query-hook-demo.tsx @@ -0,0 +1,149 @@ +'use client' + +import { cn } from '@/lib/utils' +import { + SupabaseQueryHandler, + SupabaseTableData, + SupabaseTableName, + useInfiniteQuery, +} from '@/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query' +import { Checkbox } from '@/registry/default/components/ui/checkbox' +import { Database } from '@/registry/default/fixtures/database.types' +import * as React from 'react' + +interface InfiniteListProps { + tableName: TableName + columns?: string + pageSize?: number + trailingQuery?: SupabaseQueryHandler + renderItem: (item: SupabaseTableData, index: number) => React.ReactNode + className?: string + renderNoResults?: () => React.ReactNode + renderEndMessage?: () => React.ReactNode + renderSkeleton?: (count: number) => React.ReactNode +} + +const DefaultNoResults = () => ( +
No results.
+) + +const DefaultEndMessage = () => ( +
You've reached the end.
+) + +const defaultSkeleton = (count: number) => ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+ ))} +
+) + +export function InfiniteList({ + tableName, + columns = '*', + pageSize = 20, + trailingQuery, + renderItem, + className, + renderNoResults = DefaultNoResults, + renderEndMessage = DefaultEndMessage, + renderSkeleton = defaultSkeleton, +}: InfiniteListProps) { + const { data, isFetching, hasMore, fetchNextPage, isSuccess } = useInfiniteQuery({ + tableName, + columns, + pageSize, + trailingQuery, + }) + + // Ref for the scrolling container + const scrollContainerRef = React.useRef(null) + + // Intersection observer logic - target the last rendered *item* or a dedicated sentinel + const loadMoreSentinelRef = React.useRef(null) + const observer = React.useRef(null) + + React.useEffect(() => { + if (observer.current) observer.current.disconnect() + + observer.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isFetching) { + fetchNextPage() + } + }, + { + root: scrollContainerRef.current, // Use the scroll container for scroll detection + threshold: 0.1, // Trigger when 10% of the target is visible + rootMargin: '0px 0px 100px 0px', // Trigger loading a bit before reaching the end + } + ) + + if (loadMoreSentinelRef.current) { + observer.current.observe(loadMoreSentinelRef.current) + } + + return () => { + if (observer.current) observer.current.disconnect() + } + }, [isFetching, hasMore, fetchNextPage]) + + return ( +
+
+ {isSuccess && data.length === 0 && renderNoResults()} + + {data.map((item, index) => renderItem(item, index))} + + {isFetching && renderSkeleton && renderSkeleton(pageSize)} + +
+ + {!hasMore && data.length > 0 && renderEndMessage()} +
+
+ ) +} + +type TodoTask = Database['public']['Tables']['todos']['Row'] + +// Define how each item should be rendered +const renderTodoItem = (todo: TodoTask) => { + return ( +
+
+ +
+ {todo.task} +
+ {new Date(todo.inserted_at).toLocaleDateString()} +
+
+
+
+ ) +} + +// Define a filter to only show logs with log_level = 'info' +const orderByInsertedAt: SupabaseQueryHandler<'todos'> = (query) => { + return query.order('inserted_at', { ascending: false }) +} + +const InfiniteListDemo = () => { + return ( +
+ +
+ ) +} + +export default InfiniteListDemo diff --git a/apps/ui-library/registry/default/fixtures/database.types.ts b/apps/ui-library/registry/default/fixtures/database.types.ts new file mode 100644 index 0000000000..8736e84110 --- /dev/null +++ b/apps/ui-library/registry/default/fixtures/database.types.ts @@ -0,0 +1,179 @@ +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] + +export type Database = { + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + operationName?: string + query?: string + variables?: Json + extensions?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } + public: { + Tables: { + todos: { + Row: { + id: number + inserted_at: string + is_complete: boolean | null + task: string | null + user_id: string + } + Insert: { + id?: number + inserted_at?: string + is_complete?: boolean | null + task?: string | null + user_id: string + } + Update: { + id?: number + inserted_at?: string + is_complete?: boolean | null + task?: string | null + user_id?: string + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DefaultSchema = Database[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database + } + ? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] | { schema: keyof Database }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof Database }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } + ? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: {}, + }, +} as const diff --git a/apps/ui-library/registry/default/fixtures/lib/supabase/client.ts b/apps/ui-library/registry/default/fixtures/lib/supabase/client.ts new file mode 100644 index 0000000000..2600d32f54 --- /dev/null +++ b/apps/ui-library/registry/default/fixtures/lib/supabase/client.ts @@ -0,0 +1,9 @@ +import { createBrowserClient } from '@supabase/ssr' +import { Database } from '../../database.types' +// This client is meant to be used for demo purposes only. It has types from the Supabase project in the ui-library app. +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} diff --git a/apps/ui-library/registry/examples.ts b/apps/ui-library/registry/examples.ts index e62445df81..0510f27bb4 100644 --- a/apps/ui-library/registry/examples.ts +++ b/apps/ui-library/registry/examples.ts @@ -78,4 +78,15 @@ export const examples: Registry['items'] = [ }, ], }, + { + name: 'infinite-query-hook-demo', + type: 'registry:example', + registryDependencies: [], + files: [ + { + path: 'registry/default/examples/infinite-query-hook-demo.tsx', + type: 'registry:example', + }, + ], + }, ] diff --git a/apps/ui-library/supabase/migrations/20250417083412_addTodos.sql b/apps/ui-library/supabase/migrations/20250417083412_addTodos.sql new file mode 100644 index 0000000000..adf70f1d02 --- /dev/null +++ b/apps/ui-library/supabase/migrations/20250417083412_addTodos.sql @@ -0,0 +1,99 @@ +create table "public"."todos" ( + "id" bigint generated by default as identity not null, + "user_id" uuid not null, + "task" text, + "is_complete" boolean default false, + "inserted_at" timestamp with time zone not null default timezone('utc'::text, now()) +); + + +alter table "public"."todos" enable row level security; + +CREATE UNIQUE INDEX todos_pkey ON public.todos USING btree (id); + +alter table "public"."todos" add constraint "todos_pkey" PRIMARY KEY using index "todos_pkey"; + +alter table "public"."todos" add constraint "todos_task_check" CHECK ((char_length(task) > 3)) not valid; + +alter table "public"."todos" validate constraint "todos_task_check"; + +alter table "public"."todos" add constraint "todos_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) not valid; + +alter table "public"."todos" validate constraint "todos_user_id_fkey"; + +grant delete on table "public"."todos" to "anon"; + +grant insert on table "public"."todos" to "anon"; + +grant references on table "public"."todos" to "anon"; + +grant select on table "public"."todos" to "anon"; + +grant trigger on table "public"."todos" to "anon"; + +grant truncate on table "public"."todos" to "anon"; + +grant update on table "public"."todos" to "anon"; + +grant delete on table "public"."todos" to "authenticated"; + +grant insert on table "public"."todos" to "authenticated"; + +grant references on table "public"."todos" to "authenticated"; + +grant select on table "public"."todos" to "authenticated"; + +grant trigger on table "public"."todos" to "authenticated"; + +grant truncate on table "public"."todos" to "authenticated"; + +grant update on table "public"."todos" to "authenticated"; + +grant delete on table "public"."todos" to "service_role"; + +grant insert on table "public"."todos" to "service_role"; + +grant references on table "public"."todos" to "service_role"; + +grant select on table "public"."todos" to "service_role"; + +grant trigger on table "public"."todos" to "service_role"; + +grant truncate on table "public"."todos" to "service_role"; + +grant update on table "public"."todos" to "service_role"; + +create policy "Individuals can create todos." +on "public"."todos" +as permissive +for insert +to public +with check ((auth.uid() = user_id)); + + +create policy "Individuals can delete their own todos." +on "public"."todos" +as permissive +for delete +to public +using ((( SELECT auth.uid() AS uid) = user_id)); + + +create policy "Individuals can update their own todos." +on "public"."todos" +as permissive +for update +to public +using ((( SELECT auth.uid() AS uid) = user_id)); + + +create policy "Everyone can read all todos. " +on "public"."todos" +as permissive +for select +to public +using (true); + + + + diff --git a/apps/ui-library/supabase/seed.sql b/apps/ui-library/supabase/seed.sql new file mode 100644 index 0000000000..c6cb472739 --- /dev/null +++ b/apps/ui-library/supabase/seed.sql @@ -0,0 +1,239 @@ +insert into "auth"."users" + ( + "instance_id", + "id", + "aud", + "role", + "email", + "encrypted_password", + "email_confirmed_at", + "invited_at", + "recovery_token", + "recovery_sent_at", + "last_sign_in_at", + "raw_app_meta_data", + "raw_user_meta_data", + "created_at", + "updated_at" + ) +values + ( + '00000000-0000-0000-0000-000000000000', + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'authenticated', + 'authenticated', + 'test@test.com', + '$2a$10$rIvxpnRv5waKFZIQFpMJke079cHjJlqACLZXaONomkc4FaZ4Btlbe', + '2024-02-03 23:38:34.499444+00', + '2024-02-03 23:38:21.438042+00', + 'a73278d79e14c427ad5d21509fe88963d258377f77fe9e268d0a92ed', + '2025-01-18 13:35:30.6347+00', + '2025-02-09 14:27:57.653171+00', + '{"provider": "email", "providers": ["email"]}', + '{}', + '2024-02-03 23:38:21.431361+00', + '2025-04-16 20:04:09.697799+00' + ); + +insert into "public"."todos" + ("id", "user_id", "task", "is_complete", "inserted_at") +values + (1, '8999b3f3-6465-4135-9b4f-42c750b90ffb', 'Test task', true, '2024-03-20 14:23:45.000000+00'), + (2, '8999b3f3-6465-4135-9b4f-42c750b90ffb', 'Do laundry', false, '2024-03-15 09:12:30.000000+00'), + ( + 3, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean bathroom', + true, + '2024-03-28 16:45:22.000000+00' + ), + ( + 4, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Vacuum living room', + false, + '2024-03-10 11:30:15.000000+00' + ), + ( + 5, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Mow the lawn', + true, + '2024-03-22 08:20:40.000000+00' + ), + ( + 6, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Wash dishes', + false, + '2024-03-17 19:05:33.000000+00' + ), + ( + 7, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean kitchen countertops', + true, + '2024-03-25 13:40:18.000000+00' + ), + ( + 8, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Take out trash', + false, + '2024-03-12 07:55:27.000000+00' + ), + ( + 9, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Organize closet', + true, + '2024-03-19 10:15:42.000000+00' + ), + ( + 10, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean windows', + false, + '2024-03-27 15:30:55.000000+00' + ), + ( + 11, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Water plants', + true, + '2024-03-14 12:45:10.000000+00' + ), + ( + 12, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean refrigerator', + false, + '2024-03-23 17:20:35.000000+00' + ), + ( + 13, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Dust furniture', + true, + '2024-03-16 14:10:25.000000+00' + ), + ( + 14, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean oven', + false, + '2024-03-29 09:35:50.000000+00' + ), + ( + 15, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Sweep floors', + true, + '2024-03-11 16:50:15.000000+00' + ), + ( + 16, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean microwave', + false, + '2024-03-24 08:25:40.000000+00' + ), + ( + 17, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Organize pantry', + true, + '2024-03-18 13:40:30.000000+00' + ), + ( + 18, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean shower', + false, + '2024-03-26 11:15:45.000000+00' + ), + ( + 19, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Wipe down baseboards', + true, + '2024-03-13 10:30:20.000000+00' + ), + ( + 20, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean ceiling fans', + false, + '2024-03-21 15:45:55.000000+00' + ), + ( + 21, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Organize garage', + true, + '2024-03-30 07:20:10.000000+00' + ), + ( + 22, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean gutters', + false, + '2024-03-09 14:35:25.000000+00' + ), + (23, '8999b3f3-6465-4135-9b4f-42c750b90ffb', 'Wash car', true, '2024-03-31 12:50:40.000000+00'), + ( + 24, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean pet bedding', + false, + '2024-03-08 09:05:15.000000+00' + ), + ( + 25, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Organize bookshelf', + true, + '2024-03-07 16:20:30.000000+00' + ), + ( + 26, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean air filters', + false, + '2024-03-06 13:35:45.000000+00' + ), + ( + 27, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean light fixtures', + true, + '2024-03-05 10:50:10.000000+00' + ), + ( + 28, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Organize desk', + false, + '2024-03-04 08:05:25.000000+00' + ), + ( + 29, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean patio furniture', + true, + '2024-03-03 15:20:40.000000+00' + ), + ( + 30, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Clean door handles', + false, + '2024-03-02 12:35:55.000000+00' + ), + ( + 31, + '8999b3f3-6465-4135-9b4f-42c750b90ffb', + 'Organize medicine cabinet', + true, + '2024-03-01 09:50:20.000000+00' + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2866b02285..ea740c02e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1011,28 +1011,34 @@ importers: apps/ui-library: dependencies: '@radix-ui/react-avatar': - specifier: ^1.0.4 + specifier: '*' + version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': + specifier: '*' version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': - specifier: ^2.0.2 + specifier: '*' version: 2.0.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-progress': - specifier: ^1.0.3 + specifier: '*' version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.1.2 + specifier: '*' version: 1.1.2(@types/react@18.3.3)(react@18.3.1) '@radix-ui/react-tooltip': - specifier: ^1.0.7 + specifier: '*' version: 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-router/fs-routes': specifier: ^7.4.0 version: 7.4.0(@react-router/dev@7.4.0(@types/node@22.13.14)(jiti@2.4.2)(react-router@7.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(sass@1.72.0)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(typescript@5.5.2)(vite@6.2.6(@types/node@22.13.14)(jiti@2.4.2)(sass@1.72.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.4.5))(yaml@2.4.5))(typescript@5.5.2) + '@supabase/postgrest-js': + specifier: '*' + version: 1.19.2 '@supabase/supa-mdx-lint': specifier: 0.2.6-alpha version: 0.2.6-alpha class-variance-authority: - specifier: ^0.6.0 + specifier: '*' version: 0.6.1 common: specifier: workspace:*