diff --git a/core/app/c/[communitySlug]/stages/StageList.tsx b/core/app/c/[communitySlug]/stages/StageList.tsx index 8591afc185..8e7f9f7d16 100644 --- a/core/app/c/[communitySlug]/stages/StageList.tsx +++ b/core/app/c/[communitySlug]/stages/StageList.tsx @@ -4,9 +4,10 @@ import PubRow from "../pubs/PubRow"; import { StagesData } from "./page"; import { Button } from "ui"; import Link from "next/link"; +import { Fragment } from "react"; -type Props = { stages: NonNullable, token: string }; -type IntegrationAction = { text: string, href: string, kind?: "stage" }; +type Props = { stages: NonNullable; token: string }; +type IntegrationAction = { text: string; href: string; kind?: "stage" }; const StageList: React.FC = function ({ stages, token }) { return ( @@ -17,28 +18,43 @@ const StageList: React.FC = function ({ stages, token }) {

{stage.name}

{stage.integrationInstances.map((instance) => { if (!Array.isArray(instance.integration.actions)) { - return null + return null; } - return instance.integration.actions?.map((action: IntegrationAction) => { - if (action.kind === "stage") { - const href = new URL(action.href) - href.searchParams.set('instanceId', instance.id) - href.searchParams.set('token', token) - return ( - - ) - } - }) + return ( + + {instance.integration.actions?.map( + (action: IntegrationAction) => { + if (action.kind === "stage") { + const href = new URL(action.href); + href.searchParams.set("instanceId", instance.id); + href.searchParams.set("token", token); + return ( + + ); + } + } + )} + + ); })} - + {stage.pubs.map((pub, index, list) => { - return <> - - {index < list.length - 1 &&
} - + return ( + + + {index < list.length - 1 &&
} +
+ ); })}
diff --git a/core/prisma/exampleCommunitySeeds/unjournal.ts b/core/prisma/exampleCommunitySeeds/unjournal.ts index 075ee9fc4f..68b0089ea9 100644 --- a/core/prisma/exampleCommunitySeeds/unjournal.ts +++ b/core/prisma/exampleCommunitySeeds/unjournal.ts @@ -492,30 +492,38 @@ export default async function main(prisma: PrismaClient, communityUUID: string) }, }); + const submissionsIntegrationUrl = + process.env.NODE_ENV === "production" + ? "https://integration-submissions.onrender.com" + : "http://localhost:3002"; const submissionsIntegration = await prisma.integration.create({ data: { name: "Submission Manager", actions: [ { text: "Submit Pub", - href: "https://integration-submissions.onrender.com/actions/submit", + href: `${submissionsIntegrationUrl}/actions/submit`, kind: "stage", }, ], - settingsUrl: "https://integration-submissions.onrender.com/configure", + settingsUrl: `${submissionsIntegrationUrl}/configure`, }, }); + const evaluationIntegrationUrl = + process.env.NODE_ENV === "production" + ? "https://integration-evaluations.onrender.com" + : "http://localhost:3001"; const evaluationIntegration = await prisma.integration.create({ data: { name: "Evaluation Manager", actions: [ { text: "Manage Evaluation", - href: "https://integration-evaluations.onrender.com/run", + href: `${evaluationIntegrationUrl}/actions/manage`, }, ], - settingsUrl: "https://integration-evaluations.onrender.com/configure", + settingsUrl: `${evaluationIntegrationUrl}/configure`, }, }); diff --git a/integrations/evaluations/tailwind.config.ts b/integrations/evaluations/tailwind.config.js similarity index 100% rename from integrations/evaluations/tailwind.config.ts rename to integrations/evaluations/tailwind.config.js diff --git a/integrations/submissions/app/actions/submit/actions.ts b/integrations/submissions/app/actions/submit/actions.ts index 3f8fbe5d9f..3fd04a4c77 100644 --- a/integrations/submissions/app/actions/submit/actions.ts +++ b/integrations/submissions/app/actions/submit/actions.ts @@ -6,9 +6,8 @@ import { assert, expect } from "utils"; import { client } from "~/lib/pubpub"; import { findInstance } from "~/lib/instance"; -export async function submit(form: FormData) { +export async function submit(instanceId: string, pub: Pub) { try { - const { "instance-id": instanceId, ...pub } = Object.fromEntries(form); assert(typeof instanceId === "string"); const instance = expect(await findInstance(instanceId)); return client.create(instanceId, pub as Pub, instance.pubTypeId); diff --git a/integrations/submissions/app/actions/submit/page.tsx b/integrations/submissions/app/actions/submit/page.tsx index 205e11a108..f463e2215a 100644 --- a/integrations/submissions/app/actions/submit/page.tsx +++ b/integrations/submissions/app/actions/submit/page.tsx @@ -1,22 +1,12 @@ -import { client } from "~/lib/pubpub"; import { Submit } from "./submit"; type Props = { searchParams: { instanceId: string; - token: string; }; }; export default async function Page(props: Props) { - const { instanceId, token } = props.searchParams; - const user = await client.auth(instanceId, token); - - return ( -
-

Hello {user.name}

- - -
- ); + const { instanceId } = props.searchParams; + return ; } diff --git a/integrations/submissions/app/actions/submit/submit.tsx b/integrations/submissions/app/actions/submit/submit.tsx index c4642dfada..99851d2670 100644 --- a/integrations/submissions/app/actions/submit/submit.tsx +++ b/integrations/submissions/app/actions/submit/submit.tsx @@ -1,30 +1,112 @@ "use client"; -import { useState, useTransition } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Icon, + Input, + useToast, + Card, + CardHeader, + CardFooter, + CardContent, + CardTitle, + CardDescription, +} from "ui"; +import { cn } from "utils"; +import * as z from "zod"; import { submit } from "./actions"; type Props = { instanceId: string; }; +const schema = z.object({ + Title: z.string().min(1), + instanceId: z.string(), +}); + export function Submit(props: Props) { - const [message, setMessage] = useState(""); - const [isPending, startTransition] = useTransition(); + const { toast } = useToast(); + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + Title: "", + instanceId: props.instanceId, + }, + }); - async function onSubmit(form: FormData) { - const response = await submit(form); - setMessage("error" in response ? response.error : "Pub submitted!"); + async function onSubmit(values: z.infer) { + const { instanceId, ...pub } = values; + const result = await submit(instanceId, pub); + if ("error" in result) { + toast({ + title: "Error", + description: result.error, + variant: "destructive", + }); + } else { + toast({ + title: "Success", + description: "The pub was created successfully.", + }); + } } return ( -
startTransition(() => onSubmit(form))}> - - - -

{isPending ? "Submitting Pub..." : message}

-
+
+ + + + Submit Pub + + This form will create a pub from the fields below. + + + + + ( + + Title + + + + The title of the pub. + + + )} + /> + + + + + + +
+ ); } diff --git a/integrations/submissions/app/configure/actions.ts b/integrations/submissions/app/configure/actions.ts index 18f1e78349..06b403d062 100644 --- a/integrations/submissions/app/configure/actions.ts +++ b/integrations/submissions/app/configure/actions.ts @@ -1,14 +1,10 @@ "use server"; -import { assert } from "utils"; import { updateInstance } from "~/lib/instance"; -export const configure = (form: FormData) => { - const instanceId = form.get("instance-id"); - const pubTypeId = form.get("pub-type-id"); - assert(typeof instanceId === "string"); - assert(typeof pubTypeId === "string"); +export const configure = (instanceId: string, pubTypeId: string) => { try { + // return { error: "We couldn't update the instance." }; return updateInstance(instanceId, { pubTypeId }); } catch (error) { return { error: error.message }; diff --git a/integrations/submissions/app/configure/configure.tsx b/integrations/submissions/app/configure/configure.tsx index 39e916feee..fde4c2228d 100644 --- a/integrations/submissions/app/configure/configure.tsx +++ b/integrations/submissions/app/configure/configure.tsx @@ -1,6 +1,28 @@ "use client"; -import { useState, useTransition } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Icon, + Input, + useToast, + Card, + CardHeader, + CardFooter, + CardContent, + CardTitle, + CardDescription, +} from "ui"; +import { cn } from "utils"; +import * as z from "zod"; import { configure } from "./actions"; type Props = { @@ -8,21 +30,87 @@ type Props = { pubTypeId?: string; }; +const schema = z.object({ + pubTypeId: z.string().length(36), + instanceId: z.string(), +}); + export function Configure(props: Props) { - const [message, setMessage] = useState(""); - const [isPending, startTransition] = useTransition(); + const { toast } = useToast(); + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + pubTypeId: props.pubTypeId, + instanceId: props.instanceId, + }, + }); - async function onConfigure(form: FormData) { - const response = await configure(form); - setMessage("error" in response ? response.error : "Instance configured!"); + async function onSubmit(values: z.infer) { + const result = await configure(values.instanceId, values.pubTypeId); + if ("error" in result) { + toast({ + title: "Error", + description: result.error, + variant: "destructive", + }); + } else { + toast({ + title: "Success", + description: "The instance was updated successfully.", + }); + } } return ( -
startTransition(() => onConfigure(form))}> - - - -

{isPending ? "Configuring instance..." : message}

-
+
+ + + + Submission Settings + + This form contains fields used to configure an instance of the + submissions integration. + + + + + ( + + Pub Type + + + + + The pub type determines the fields available on the + submission form. + + + + )} + /> + + + + + + +
+ ); } diff --git a/integrations/submissions/app/configure/page.tsx b/integrations/submissions/app/configure/page.tsx index 29f44637b3..84378a5a17 100644 --- a/integrations/submissions/app/configure/page.tsx +++ b/integrations/submissions/app/configure/page.tsx @@ -1,23 +1,14 @@ -import { client } from "~/lib/pubpub"; import { findInstance } from "~/lib/instance"; import { Configure } from "./configure"; type Props = { searchParams: { instanceId: string; - token: string; }; }; export default async function Page(props: Props) { - const { instanceId, token } = props.searchParams; - const user = await client.auth(instanceId, token); + const { instanceId } = props.searchParams; const instance = await findInstance(instanceId); - return ( -
-

Hello {user.name}

- - -
- ); + return ; } diff --git a/integrations/submissions/app/favicon.ico b/integrations/submissions/app/favicon.ico index 718d6fea48..fe768c9b4a 100644 Binary files a/integrations/submissions/app/favicon.ico and b/integrations/submissions/app/favicon.ico differ diff --git a/integrations/submissions/app/layout.tsx b/integrations/submissions/app/layout.tsx index eda1917abd..454a3c7662 100644 --- a/integrations/submissions/app/layout.tsx +++ b/integrations/submissions/app/layout.tsx @@ -1,4 +1,10 @@ +import { headers } from "next/headers"; +import { Toaster } from "ui"; import "ui/styles.css"; +import { expect } from "utils"; +import { Integration } from "~/lib/Integration"; +import { Instance, findInstance } from "~/lib/instance"; +import { client } from "~/lib/pubpub"; import "./globals.css"; export const metadata = { @@ -6,12 +12,29 @@ export const metadata = { description: "", }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + // This header is set in the root middleware module, which allows layouts + // to fetch data using search parameters. + const search = expect(headers().get("x-next-search")); + const searchParams = new URLSearchParams(search); + const instanceId = expect(searchParams.get("instanceId")); + const token = expect(searchParams.get("token")); + const user = await client.auth(instanceId, token); + let instance: Instance | undefined; + if (instanceId) { + instance = await findInstance(instanceId); + } return ( -

Submissions

- {children} + + {children} + + ); diff --git a/integrations/submissions/lib/Integration.tsx b/integrations/submissions/lib/Integration.tsx new file mode 100644 index 0000000000..ccd8bd050f --- /dev/null +++ b/integrations/submissions/lib/Integration.tsx @@ -0,0 +1,7 @@ +"use client"; + +// Although `Integration` is bundled with the "use client" directive, Next +// surfaces an error that the component has no parents with "use client". This +// intermediate module instructs Next to treat `Integration` as a client +// component. +export { Integration } from "@pubpub/sdk/react"; diff --git a/integrations/submissions/middleware.ts b/integrations/submissions/middleware.ts new file mode 100644 index 0000000000..6e0873900d --- /dev/null +++ b/integrations/submissions/middleware.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export default function middleware(request: NextRequest) { + // Add search params to the request headers so layouts can use them to fetch + // data, like the integration settings and user info. + const headers = new Headers(request.headers); + headers.set("x-next-search", request.nextUrl.search); + + return NextResponse.next({ + request: { + headers, + }, + }); +} + +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/integrations/submissions/package.json b/integrations/submissions/package.json index b8365263f1..bae6885df6 100644 --- a/integrations/submissions/package.json +++ b/integrations/submissions/package.json @@ -9,15 +9,18 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.3.1", "@pubpub/sdk": "workspace:*", "clsx": "^2.0.0", "next": "13.4.5", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.46.1", "redis": "^4.6.8", "tailwind-merge": "^1.14.0", "ui": "workspace:*", - "utils": "workspace:*" + "utils": "workspace:*", + "zod": "^3.21.4" }, "devDependencies": { "@types/node": "20.3.1", diff --git a/integrations/submissions/tailwind.config.js b/integrations/submissions/tailwind.config.js new file mode 100644 index 0000000000..58a1c18356 --- /dev/null +++ b/integrations/submissions/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +const path = require("path"); + +const sharedConfig = require("ui/tailwind.config.js"); +const resolve = (packageName) => + path.join(path.dirname(require.resolve(packageName)), "**/*.{js,cjs,mjs}"); + +module.exports = { + presets: [sharedConfig], + content: ["./app/**/*.{ts,tsx}", resolve("@pubpub/sdk/react"), resolve("ui")], +}; diff --git a/integrations/submissions/tailwind.config.ts b/integrations/submissions/tailwind.config.ts deleted file mode 100644 index 76b14050fd..0000000000 --- a/integrations/submissions/tailwind.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('tailwindcss').Config} */ - -const sharedConfig = require("ui/tailwind.config.js"); - -module.exports = { - presets: [sharedConfig], - content: [ - "./app/**/*.{ts,tsx}", - // "../packages/ui/**/*.{js,ts,jsx,tsx}" - // "./node_modules/ui/**/*.{js,ts,jsx,tsx}" - ], -}; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cd36ec5f08..61944facef 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -3,16 +3,21 @@ "type": "module", "version": "0.0.6", "description": "", - "main": "dist/index.js", + "main": "dist/lib/index.js", "files": [ "dist" ], - "types": "./dist/index.d.ts", + "types": "./dist/lib/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "require": "./dist/index.cjs", - "types": "./dist/index.d.ts" + "import": "./dist/lib/index.js", + "require": "./dist/lib/index.cjs", + "types": "./dist/lib/index.d.ts" + }, + "./react": { + "import": "./dist/react/index.js", + "require": "./dist/react/index.cjs", + "types": "./dist/react/index.d.ts" } }, "scripts": { @@ -27,10 +32,19 @@ ], "author": "Knowledge Futures, Inc ", "license": "GPL-2.0+", + "dependencies": { + "utils": "workspace:*" + }, + "peerDependencies": { + "ui": "workspace:*", + "react": "^18.2.0" + }, "devDependencies": { "@types/node": "^20.4.2", + "@types/react": "^18.2.5", "tsconfig": "workspace:*", "tsup": "^7.1.0", + "ui": "workspace:*", "vite": "^4.4.5" } } diff --git a/packages/sdk/react/package.json b/packages/sdk/react/package.json new file mode 100644 index 0000000000..9771c7eedd --- /dev/null +++ b/packages/sdk/react/package.json @@ -0,0 +1,11 @@ +{ + "main": "../dist/react/index.js", + "types": "../dist/react/index.d.ts", + "exports": { + ".": { + "import": "./../dist/react/index.js", + "require": "./../dist/react/index.cjs", + "types": "./../dist/react/index.d.ts" + } + } +} diff --git a/packages/sdk/src/lib/client.ts b/packages/sdk/src/lib/client.ts index 26e7f9e88c..f694baceca 100644 --- a/packages/sdk/src/lib/client.ts +++ b/packages/sdk/src/lib/client.ts @@ -1,16 +1,5 @@ import { ValidationError, PubPubError, ResponseError, ZodError } from "./errors"; -import { Manifest } from "./types"; - -// TODO: derive this type from core API contract -export type User = { - id: string; - slug: string; - email: string; - name: string; - avatar?: string | null; - createdAt: Date; - updatedAt: Date; -}; +import { Manifest, User } from "./types"; export type Get = ( | Extract diff --git a/packages/sdk/src/lib/types.ts b/packages/sdk/src/lib/types.ts index e6ff3903eb..edbdd8f768 100644 --- a/packages/sdk/src/lib/types.ts +++ b/packages/sdk/src/lib/types.ts @@ -1,5 +1,16 @@ export type Manifest = { - read?: { [key: string]: { id: string } } - write?: { [key: string]: { id: string } } - register?: { [key: string]: { id: string } } -} + read?: { [key: string]: { id: string } }; + write?: { [key: string]: { id: string } }; + register?: { [key: string]: { id: string } }; +}; + +// TODO: derive this type from core API contract +export type User = { + id: string; + slug: string; + email: string; + name: string; + avatar?: string | null; + createdAt: Date; + updatedAt: Date; +}; diff --git a/packages/sdk/src/react/Integration.tsx b/packages/sdk/src/react/Integration.tsx new file mode 100644 index 0000000000..51aedadf25 --- /dev/null +++ b/packages/sdk/src/react/Integration.tsx @@ -0,0 +1,16 @@ +"use client"; + +import * as React from "react"; +import { IntegrationLayout } from "./IntegrationLayout"; +import { IntegrationProvider, IntegrationProviderProps } from "./IntegrationProvider"; + +export type IntegrationProps = IntegrationProviderProps; + +export const Integration = (props: IntegrationProps) => { + const { children, ...options } = props; + return ( + + {children} + + ); +}; diff --git a/packages/sdk/src/react/IntegrationAvatar.tsx b/packages/sdk/src/react/IntegrationAvatar.tsx new file mode 100644 index 0000000000..342c64e7f2 --- /dev/null +++ b/packages/sdk/src/react/IntegrationAvatar.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { Avatar, AvatarImage, AvatarFallback } from "ui"; + +type Props = { + name: string; + url: string; +}; + +export const IntegrationAvatar = (props: Props) => { + return ( + + + {props.name[0]} + + ); +}; diff --git a/packages/sdk/src/react/IntegrationContext.tsx b/packages/sdk/src/react/IntegrationContext.tsx new file mode 100644 index 0000000000..42daadd87f --- /dev/null +++ b/packages/sdk/src/react/IntegrationContext.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { createContext } from "react"; +import { User } from "../lib"; + +export type IntegrationContext = { + name: string; + user: User; + instance: unknown; +}; + +export const IntegrationContext = createContext({ + name: "", + user: null!, + instance: null, +}); diff --git a/packages/sdk/src/react/IntegrationLayout.tsx b/packages/sdk/src/react/IntegrationLayout.tsx new file mode 100644 index 0000000000..a57c9f0b36 --- /dev/null +++ b/packages/sdk/src/react/IntegrationLayout.tsx @@ -0,0 +1,15 @@ +"use client"; + +import * as React from "react"; +import { IntegrationLayoutHeader } from "./IntegrationLayoutHeader"; + +export const IntegrationLayout = (props: React.PropsWithChildren) => { + return ( +
+
+ +
{props.children}
+
+
+ ); +}; diff --git a/packages/sdk/src/react/IntegrationLayoutHeader.tsx b/packages/sdk/src/react/IntegrationLayoutHeader.tsx new file mode 100644 index 0000000000..ad5a00996b --- /dev/null +++ b/packages/sdk/src/react/IntegrationLayoutHeader.tsx @@ -0,0 +1,20 @@ +"use client"; + +import * as React from "react"; +import { cn } from "utils"; +import { PubpubLogo } from "./PubPubLogo"; +import { useIntegration } from "./IntegrationProvider"; +import { IntegrationAvatar } from "./IntegrationAvatar"; + +export const IntegrationLayoutHeader = () => { + const { name, user } = useIntegration(); + return ( +
+
+ + +
+

{name}

+
+ ); +}; diff --git a/packages/sdk/src/react/IntegrationProvider.tsx b/packages/sdk/src/react/IntegrationProvider.tsx new file mode 100644 index 0000000000..5c9c86ff42 --- /dev/null +++ b/packages/sdk/src/react/IntegrationProvider.tsx @@ -0,0 +1,17 @@ +"use client"; + +import * as React from "react"; +import { useContext } from "react"; +import { IntegrationContext } from "./IntegrationContext"; + +export type IntegrationProviderProps = React.PropsWithChildren; + +export const IntegrationProvider = (props: IntegrationProviderProps) => { + return ( + {props.children} + ); +}; + +export const useIntegration = () => { + return useContext(IntegrationContext); +}; diff --git a/packages/sdk/src/react/PubPubLogo.tsx b/packages/sdk/src/react/PubPubLogo.tsx new file mode 100644 index 0000000000..62d388ffcb --- /dev/null +++ b/packages/sdk/src/react/PubPubLogo.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +type Props = React.SVGProps; + +export const PubpubLogo = (props: Props) => ( + + + + + + + + +); diff --git a/packages/sdk/src/react/index.tsx b/packages/sdk/src/react/index.tsx new file mode 100644 index 0000000000..b31d516882 --- /dev/null +++ b/packages/sdk/src/react/index.tsx @@ -0,0 +1,6 @@ +export * from "./IntegrationLayoutHeader"; +export * from "./IntegrationLayout"; +export * from "./PubPubLogo"; +export * from "./IntegrationContext"; +export * from "./IntegrationProvider"; +export * from "./Integration"; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index c1bd294ce6..b3dae4411d 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "tsconfig/react-library.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "jsx": "react" }, "include": ["."], "exclude": ["dist", "build", "node_modules"] diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index 7a9e83fa5a..f73bf0144e 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -2,8 +2,8 @@ import { defineConfig, Options } from "tsup"; export default defineConfig((options: Options) => ({ treeshake: true, - splitting: true, - entry: ["src/**/*.ts"], + splitting: false, + entry: ["src/lib/index.ts", "src/react/**/*.tsx"], format: ["esm", "cjs"], dts: true, minify: true, diff --git a/packages/ui/package.json b/packages/ui/package.json index 83015ae02e..e3caf8b2b3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,9 +4,10 @@ "sideEffects": [ "**/*.css" ], + "main": "./dist/index.mjs", "types": "./dist/index.d.mts", "exports": { - ".": "./dist", + ".": "./dist/index.mjs", "./styles.css": "./dist/index.css", "./tailwind.config.js": "./tailwind.config.js" }, @@ -16,27 +17,35 @@ "type-check": "tsc" }, "dependencies": { + "@hookform/resolvers": "^3.3.1", "@radix-ui/react-avatar": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.6", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.4", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", + "lucide-react": "^0.274.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.6", "utils": "workspace:*" }, "peerDependencies": { "react": "^18.2.0", - "tailwindcss": "^3.3.3" + "react-hook-form": "^7.46.1", + "tailwindcss": "^3.3.3", + "zod": "^3.21.4" }, "devDependencies": { "@types/react": "^18.2.5", "postcss": "^8.4.20", "react": "^18.2.0", + "react-hook-form": "^7.46.1", "tsconfig": "workspace:*", "tsup": "^7.2.0", - "typescript": "^4.9.4" + "typescript": "^4.9.4", + "zod": "^3.21.4" } } diff --git a/packages/ui/src/alert.tsx b/packages/ui/src/alert.tsx new file mode 100644 index 0000000000..a146938af9 --- /dev/null +++ b/packages/ui/src/alert.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx new file mode 100644 index 0000000000..48b62fd5e7 --- /dev/null +++ b/packages/ui/src/form.tsx @@ -0,0 +1,169 @@ +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 "utils"; +import { Label } from "./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>( + ({ 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 ( +