diff --git a/app/create_index.py b/app/index.py similarity index 100% rename from app/create_index.py rename to app/index.py diff --git a/frontend/package.json b/frontend/package.json index 30af386..d5ec4a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,9 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.3.1", + "@orama/orama": "^1.2.3", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", "@tanstack/react-table": "^8.9.3", "@types/node": "20.5.1", @@ -24,10 +27,11 @@ "postcss": "8.4.28", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.46.1", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.6", "typescript": "5.1.6", - "zod": "^3.22.2" + "zod": "^3.21.4" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ae5d49a..bccf984 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -5,6 +5,15 @@ settings: excludeLinksFromLockfile: false dependencies: + "@hookform/resolvers": + specifier: ^3.3.1 + version: 3.3.1(react-hook-form@7.46.1) + "@orama/orama": + specifier: ^1.2.3 + version: 1.2.3 + "@radix-ui/react-label": + specifier: ^2.0.2 + version: 2.0.2(@types/react-dom@18.2.7)(@types/react@18.2.20)(react-dom@18.2.0)(react@18.2.0) "@radix-ui/react-slot": specifier: ^1.0.2 version: 1.0.2(@types/react@18.2.20)(react@18.2.0) @@ -50,6 +59,9 @@ dependencies: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.46.1 + version: 7.46.1(react@18.2.0) tailwind-merge: specifier: ^1.14.0 version: 1.14.0 @@ -63,8 +75,8 @@ dependencies: specifier: 5.1.6 version: 5.1.6 zod: - specifier: ^3.22.2 - version: 3.22.2 + specifier: ^3.21.4 + version: 3.21.4 packages: /@aashutoshrathi/word-wrap@1.2.6: @@ -142,6 +154,17 @@ packages: engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } dev: false + /@hookform/resolvers@3.3.1(react-hook-form@7.46.1): + resolution: + { + integrity: sha512-K7KCKRKjymxIB90nHDQ7b9nli474ru99ZbqxiqDAWYsYhOsU3/4qLxW91y+1n04ic13ajjZ66L3aXbNef8PELQ==, + } + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.46.1(react@18.2.0) + dev: false + /@humanwhocodes/config-array@0.11.10: resolution: { @@ -370,6 +393,14 @@ packages: fastq: 1.15.0 dev: false + /@orama/orama@1.2.3: + resolution: + { + integrity: sha512-KJ4lzTDluQOJu6l2xsmDjKdhU6EvldmshvsQgAvDORn/Db+EXaWOKSK4XdvUNIcpUeSbFAdkRB26NLlfSpWRGg==, + } + engines: { node: ">= 16.0.0" } + dev: false + /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.20)(react@18.2.0): resolution: { @@ -387,6 +418,54 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-label@2.0.2(@types/react-dom@18.2.7)(@types/react@18.2.20)(react-dom@18.2.0)(react@18.2.0): + resolution: + { + integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + dependencies: + "@babel/runtime": 7.22.10 + "@radix-ui/react-primitive": 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.20)(react-dom@18.2.0)(react@18.2.0) + "@types/react": 18.2.20 + "@types/react-dom": 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.20)(react-dom@18.2.0)(react@18.2.0): + resolution: + { + integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + dependencies: + "@babel/runtime": 7.22.10 + "@radix-ui/react-slot": 1.0.2(@types/react@18.2.20)(react@18.2.0) + "@types/react": 18.2.20 + "@types/react-dom": 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-slot@1.0.2(@types/react@18.2.20)(react@18.2.0): resolution: { @@ -2964,6 +3043,18 @@ packages: scheduler: 0.23.0 dev: false + /react-hook-form@7.46.1(react@18.2.0): + resolution: + { + integrity: sha512-0GfI31LRTBd5tqbXMGXT1Rdsv3rnvy0FjEk8Gn9/4tp6+s77T7DPZuGEpBRXOauL+NhyGT5iaXzdIM2R6F/E+w==, + } + engines: { node: ">=12.22.0" } + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: { @@ -3707,10 +3798,3 @@ packages: integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==, } dev: false - - /zod@3.22.2: - resolution: - { - integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==, - } - dev: false diff --git a/frontend/src/app/columns.tsx b/frontend/src/app/columns.tsx index d2043b9..ba06803 100644 --- a/frontend/src/app/columns.tsx +++ b/frontend/src/app/columns.tsx @@ -31,8 +31,8 @@ export const columns: ColumnDef[] = [ - + + + ); } diff --git a/frontend/src/app/repos-search-provider.tsx b/frontend/src/app/repos-search-provider.tsx new file mode 100644 index 0000000..f366e0c --- /dev/null +++ b/frontend/src/app/repos-search-provider.tsx @@ -0,0 +1,31 @@ +"use client"; +import { + createReposOrama, + prepareReposOramaIndex, + ReposOramaContext, +} from "@/lib/search"; +import { PropsWithChildren } from "react"; +import { SearchProvider } from "./search-provider"; +import { Index } from "@/lib/schemas"; + +export function ReposSearchProvider({ + children, + repos, +}: PropsWithChildren<{ + repos: Index["repos"]; +}>) { + const prepareOramaIndex = async () => { + const orama = await createReposOrama(); + await prepareReposOramaIndex(orama, repos); + return orama; + }; + + return ( + + {children} + + ); +} diff --git a/frontend/src/app/repos-table.tsx b/frontend/src/app/repos-table.tsx new file mode 100644 index 0000000..2367ad3 --- /dev/null +++ b/frontend/src/app/repos-table.tsx @@ -0,0 +1,38 @@ +"use client"; +import { Index, Repo } from "@/lib/schemas"; +import { search } from "@orama/orama"; +import { SearchForm } from "./search-form"; +import { columns } from "./columns"; +import { DataTable } from "./data-table"; +import { useReposOrama } from "@/lib/search"; +import { useState } from "react"; + +export function ReposTable({ repos }: Index) { + const reposOrama = useReposOrama(); + const [searchedRepos, setSearchedRepos] = useState(repos); + + const onSearchSubmit = async ({ + search: description, + }: { + search: string; + }) => { + if (!reposOrama.isIndexed || !reposOrama.orama) { + throw new Error("Orama is not initialized"); + } + const results = await search(reposOrama.orama, { + term: description, + properties: ["description"], + limit: repos.length, + }); + setSearchedRepos(results.hits.map((hit) => hit.document as Repo)); + }; + + return ( + <> +
+ +
+ + + ); +} diff --git a/frontend/src/app/search-form.tsx b/frontend/src/app/search-form.tsx new file mode 100644 index 0000000..814a1f5 --- /dev/null +++ b/frontend/src/app/search-form.tsx @@ -0,0 +1,56 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const FormSchema = z.object({ + search: z + .string() + .max(1024, { message: "Search must be less than 1024 characters" }), +}); + +export interface SearchFormProps { + onSubmit: (data: z.infer) => void; +} + +export function SearchForm({ onSubmit }: SearchFormProps) { + const form = useForm>({ + resolver: zodResolver(FormSchema), + }); + + return ( +
+ + ( + + Search for a repository + + + + + The search is performed on the repository description. + + + + )} + /> + + + + ); +} diff --git a/frontend/src/app/search-provider.tsx b/frontend/src/app/search-provider.tsx new file mode 100644 index 0000000..e89a68d --- /dev/null +++ b/frontend/src/app/search-provider.tsx @@ -0,0 +1,38 @@ +"use client"; +import { IOramaContext } from "@/lib/search"; +import { Context, PropsWithChildren, useEffect, useState } from "react"; + +import { Orama, ProvidedTypes as OramaProvidedTypes } from "@orama/orama"; + +export type SearchProviderProps< + OramaParameters extends Partial = any, +> = PropsWithChildren<{ + OramaContext: Context>; + createIndex: () => Promise>; +}>; + +export function SearchProvider< + OramaParameters extends Partial, +>({ + children, + OramaContext, + createIndex, +}: SearchProviderProps) { + const [orama, setOrama] = useState | null>(null); + const [isIndexed, setIsIndexed] = useState(false); + + useEffect(() => { + async function initializeOrama() { + setIsIndexed(false); + await createIndex().then(setOrama); + setIsIndexed(true); + } + initializeOrama(); + }, [createIndex]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..497718a --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +