From ec1745c3c454fa6aecec9eaf98913d999a3ca1ed Mon Sep 17 00:00:00 2001 From: towelie Date: Tue, 17 Dec 2024 19:07:19 +0100 Subject: [PATCH] added searchable select component, added new items page, removed old items page --- package-lock.json | 69 ++++++-- package.json | 3 +- src/actions/items.ts | 7 +- src/app/(app)/items/page.tsx | 57 +++---- src/components/ItemList.tsx | 27 ++++ src/components/SearchableSelect.tsx | 89 ++++++++++ src/components/forms/AddItemForm.tsx | 22 ++- src/components/skeltons/SkeletonItemsList.tsx | 21 +++ src/components/ui/command.tsx | 153 ++++++++++++++++++ src/hooks/use-searchable-select.tsx | 27 ++++ src/lib/supabase/complex_types.ts | 2 +- src/lib/supabase/types_db.ts | 45 ++++++ ...0241217171656_added_ites_table_and_rls.sql | 76 +++++++++ 13 files changed, 550 insertions(+), 48 deletions(-) create mode 100644 src/components/ItemList.tsx create mode 100644 src/components/SearchableSelect.tsx create mode 100644 src/components/skeltons/SkeletonItemsList.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/hooks/use-searchable-select.tsx create mode 100644 supabase/migrations/20241217171656_added_ites_table_and_rls.sql diff --git a/package-lock.json b/package-lock.json index 6d0e52e..9d676cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", - "@radix-ui/react-dialog": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.4", @@ -24,6 +24,7 @@ "@supabase/supabase-js": "^2.47.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.468.0", "motion": "^11.13.5", @@ -989,15 +990,15 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.3.tgz", - "integrity": "sha512-ujGvqQNkZ0J7caQyl8XuZRj2/TIrYcOGwqz5TeD1OMcCdfBuEMP0D12ve+8J5F9XuNUth3FAKFWo/wt0E/GJrQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", + "integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", @@ -1007,7 +1008,7 @@ "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "react-remove-scroll": "^2.6.1" }, "peerDependencies": { "@types/react": "*", @@ -1046,9 +1047,9 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.2.tgz", - "integrity": "sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.1", @@ -1168,6 +1169,31 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", + "integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -3284,6 +3310,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz", + "integrity": "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.0", + "use-sync-external-store": "^1.2.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -7250,6 +7292,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 27d919d..0009014 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", - "@radix-ui/react-dialog": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.4", @@ -36,6 +36,7 @@ "@supabase/supabase-js": "^2.47.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.468.0", "motion": "^11.13.5", diff --git a/src/actions/items.ts b/src/actions/items.ts index 8270c91..aeb342e 100644 --- a/src/actions/items.ts +++ b/src/actions/items.ts @@ -4,11 +4,11 @@ import { DBResult, Item, RevalidationPaths } from "@/lib/supabase/complex_types" import { createClient } from "@/lib/supabase/server"; import { revalidateAll } from "@/lib/utils"; -export async function createItem(itemData: Omit, revalidatePaths: RevalidationPaths): DBResult { +export async function createItem(itemData: Omit, revalidatePaths: RevalidationPaths): DBResult { const supabase = await createClient() const result = await supabase - .from('shopping_items') + .from('item') .insert(itemData) .single(); @@ -19,7 +19,8 @@ export async function createItem(itemData: Omit, reva export async function getItems(): DBResult { return (await createClient()) - .from('shopping_items') + .from('item') .select(`*`) + .order('name', { ascending: true }) } diff --git a/src/app/(app)/items/page.tsx b/src/app/(app)/items/page.tsx index 76d9ff8..26e74da 100644 --- a/src/app/(app)/items/page.tsx +++ b/src/app/(app)/items/page.tsx @@ -1,41 +1,34 @@ -import { getItems } from "@/actions/items"; -import AddItemForm from "@/components/forms/AddItemForm"; -import { Card, CardContent } from "@/components/ui/card"; -import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; -import { Item } from "@/lib/supabase/complex_types"; -import { notFound } from "next/navigation"; +import { getItems } from "@/actions/items" +import DebugState from "@/components/DebugState" +import AddItemForm from "@/components/forms/AddItemForm" +import ItemList from "@/components/ItemList" +import { SkeletonItemsList } from "@/components/skeltons/SkeletonItemsList" +import { Suspense } from "react" export default async function ItemsPage() { - const { data: items, error } = await getItems() + return ( +
+ +
+

All Items

+ }> + + +
+
+ ) +} - if (error) { - throw error - } +async function ItemsWrapper() { + const items = await getItems() - if (!items) { - notFound() - } + console.log("items", items) return ( -
-

items

- - - - - - - {items.map((item: Item) => ( - - {item.name} - {item.type} - {item.order} - - ))} - -
-
-
+
+ +
) } + diff --git a/src/components/ItemList.tsx b/src/components/ItemList.tsx new file mode 100644 index 0000000..c6c9ed8 --- /dev/null +++ b/src/components/ItemList.tsx @@ -0,0 +1,27 @@ +"use client" + +import { Item } from "@/lib/supabase/complex_types" +import { Card, CardHeader } from "./ui/card" +import { EllipsisVertical } from "lucide-react" + +export default function ItemList({ items }: { items: Item[] }) { + return ( +
+ { + items.map((item: Item) => ( + + +
+

+ {item.name} +

+ +
+
+
+ )) + } +
+ ) +} + diff --git a/src/components/SearchableSelect.tsx b/src/components/SearchableSelect.tsx new file mode 100644 index 0000000..213f89c --- /dev/null +++ b/src/components/SearchableSelect.tsx @@ -0,0 +1,89 @@ +import * as React from "react" +import { Check, ChevronsUpDown } from 'lucide-react' + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { useSearchableSelect } from "@/hooks/use-searchable-select" + +interface SearchableSelectProps { + options: Record + onChange: (value: string) => void + placeholder?: string +} + +export function SearchableSelect({ + options, + onChange, + placeholder = "Select an item...", +}: SearchableSelectProps) { + const { + open, + setOpen, + value, + setValue, + search, + setSearch, + filteredOptions, + } = useSearchableSelect(options) + + return ( + + + + + + + + No item found. + + {filteredOptions.map(([key, label]) => ( + { + setValue(currentValue === value ? "" : currentValue) + onChange(currentValue) + setOpen(false) + }} + > + + {label} + + ))} + + + + + ) +} + + diff --git a/src/components/forms/AddItemForm.tsx b/src/components/forms/AddItemForm.tsx index 7c6ced4..6d9b290 100644 --- a/src/components/forms/AddItemForm.tsx +++ b/src/components/forms/AddItemForm.tsx @@ -5,15 +5,28 @@ import { Input } from "../ui/input"; import { Button } from "../ui/button"; import { useState } from "react"; import { createItem } from "@/actions/items"; +import { SearchableSelect } from "../SearchableSelect"; export default function AddItemForm() { const [name, setName] = useState("") + const options = { + apple: "Apple", + banana: "Banana", + cherry: "Cherry", + date: "Date", + elderberry: "Elderberry", + fig: "Fig", + grape: "Grape", + } + + const handleSubmit = async () => { await createItem({ name: name, - order: 1000, - type: 'Lebensmittel' + type_id: "", + store_id: "", + is_favorite: false }, ['/items']) } @@ -24,6 +37,11 @@ export default function AddItemForm() { setName(e.currentTarget.value)} /> + console.log("Selected:", value)} + placeholder="Select..." + /> diff --git a/src/components/skeltons/SkeletonItemsList.tsx b/src/components/skeltons/SkeletonItemsList.tsx new file mode 100644 index 0000000..f7776f0 --- /dev/null +++ b/src/components/skeltons/SkeletonItemsList.tsx @@ -0,0 +1,21 @@ +import { Card, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function SkeletonItemsList() { + return ( +
+ {[...Array(6)].map((_, index) => ( + + +
+ + +
+
+
+ ))} +
+ ); +} + + diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..2cecd91 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/hooks/use-searchable-select.tsx b/src/hooks/use-searchable-select.tsx new file mode 100644 index 0000000..73ed904 --- /dev/null +++ b/src/hooks/use-searchable-select.tsx @@ -0,0 +1,27 @@ +import { useState, useMemo } from 'react' + +export function useSearchableSelect(options: Record) { + const [open, setOpen] = useState(false) + const [value, setValue] = useState('') + const [search, setSearch] = useState('') + + const filteredOptions = useMemo(() => { + const entries = Object.entries(options) + if (!search) return entries + return entries.filter(([_, label]) => + label.toLowerCase().includes(search.toLowerCase()) + ) + }, [options, search]) + + return { + open, + setOpen, + value, + setValue, + search, + setSearch, + filteredOptions, + } +} + + diff --git a/src/lib/supabase/complex_types.ts b/src/lib/supabase/complex_types.ts index e423f6d..80515a3 100644 --- a/src/lib/supabase/complex_types.ts +++ b/src/lib/supabase/complex_types.ts @@ -34,7 +34,7 @@ export type Store = Tables<'stores'>; * NOTE: Items */ -export type Item = Tables<'shopping_items'>; +export type Item = Tables<'item'>; export type ItemTypeEnum = Database['public']['Enums']['shopping_item_types'] export type ItemType = Tables<'types'>; diff --git a/src/lib/supabase/types_db.ts b/src/lib/supabase/types_db.ts index 0878ada..c2d3faa 100644 --- a/src/lib/supabase/types_db.ts +++ b/src/lib/supabase/types_db.ts @@ -9,6 +9,51 @@ export type Json = export type Database = { public: { Tables: { + item: { + Row: { + created_at: string + id: string + is_favorite: boolean + last_bought: string | null + name: string | null + store_id: string + type_id: string + } + Insert: { + created_at?: string + id?: string + is_favorite?: boolean + last_bought?: string | null + name?: string | null + store_id: string + type_id: string + } + Update: { + created_at?: string + id?: string + is_favorite?: boolean + last_bought?: string | null + name?: string | null + store_id?: string + type_id?: string + } + Relationships: [ + { + foreignKeyName: "item_store_id_fkey" + columns: ["store_id"] + isOneToOne: false + referencedRelation: "stores" + referencedColumns: ["id"] + }, + { + foreignKeyName: "item_type_id_fkey" + columns: ["type_id"] + isOneToOne: false + referencedRelation: "types" + referencedColumns: ["id"] + }, + ] + } recepies: { Row: { created_at: string diff --git a/supabase/migrations/20241217171656_added_ites_table_and_rls.sql b/supabase/migrations/20241217171656_added_ites_table_and_rls.sql new file mode 100644 index 0000000..a77cc9c --- /dev/null +++ b/supabase/migrations/20241217171656_added_ites_table_and_rls.sql @@ -0,0 +1,76 @@ +create table "public"."item" ( + "id" uuid not null default gen_random_uuid(), + "created_at" timestamp with time zone not null default now(), + "name" text, + "type_id" uuid not null, + "store_id" uuid not null, + "last_bought" timestamp with time zone, + "is_favorite" boolean not null default false +); + + +alter table "public"."item" enable row level security; + +CREATE UNIQUE INDEX item_pkey ON public.item USING btree (id); + +alter table "public"."item" add constraint "item_pkey" PRIMARY KEY using index "item_pkey"; + +alter table "public"."item" add constraint "item_store_id_fkey" FOREIGN KEY (store_id) REFERENCES stores(id) not valid; + +alter table "public"."item" validate constraint "item_store_id_fkey"; + +alter table "public"."item" add constraint "item_type_id_fkey" FOREIGN KEY (type_id) REFERENCES types(id) not valid; + +alter table "public"."item" validate constraint "item_type_id_fkey"; + +grant delete on table "public"."item" to "anon"; + +grant insert on table "public"."item" to "anon"; + +grant references on table "public"."item" to "anon"; + +grant select on table "public"."item" to "anon"; + +grant trigger on table "public"."item" to "anon"; + +grant truncate on table "public"."item" to "anon"; + +grant update on table "public"."item" to "anon"; + +grant delete on table "public"."item" to "authenticated"; + +grant insert on table "public"."item" to "authenticated"; + +grant references on table "public"."item" to "authenticated"; + +grant select on table "public"."item" to "authenticated"; + +grant trigger on table "public"."item" to "authenticated"; + +grant truncate on table "public"."item" to "authenticated"; + +grant update on table "public"."item" to "authenticated"; + +grant delete on table "public"."item" to "service_role"; + +grant insert on table "public"."item" to "service_role"; + +grant references on table "public"."item" to "service_role"; + +grant select on table "public"."item" to "service_role"; + +grant trigger on table "public"."item" to "service_role"; + +grant truncate on table "public"."item" to "service_role"; + +grant update on table "public"."item" to "service_role"; + +create policy "Allow all CRUD" +on "public"."item" +as permissive +for all +to public +using (true); + + +