From 37184a2394ee86a2f7b65542b0cb6cf432e823e3 Mon Sep 17 00:00:00 2001 From: Derock Date: Mon, 18 Dec 2023 21:53:34 -0500 Subject: [PATCH] wip: service stuff --- package.json | 2 + pnpm-lock.yaml | 23 +++ .../[id]/_components/CreateService.tsx | 114 ++++++++++- .../[id]/_components/DeployChanges.tsx | 0 .../[id]/_components/ProjectLayout.tsx | 110 +++++++++++ .../project/[id]/_context/ProjectContext.tsx | 25 +++ src/app/(dashboard)/project/[id]/layout.tsx | 87 +-------- src/components/ui/form.tsx | 177 ++++++++++++++++++ src/components/ui/required.tsx | 4 +- src/env.ts | 5 + src/hooks/forms.tsx | 22 +++ src/server/api/middleware/project.ts | 44 +++++ src/server/api/routers/projects/index.ts | 3 + src/server/api/routers/projects/project.ts | 7 +- .../api/routers/projects/service/index.ts | 50 +++++ src/server/api/trpc.ts | 90 +++++---- src/server/utils/zod.ts | 5 + 17 files changed, 633 insertions(+), 135 deletions(-) create mode 100644 src/app/(dashboard)/project/[id]/_components/DeployChanges.tsx create mode 100644 src/app/(dashboard)/project/[id]/_components/ProjectLayout.tsx create mode 100644 src/app/(dashboard)/project/[id]/_context/ProjectContext.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/hooks/forms.tsx create mode 100644 src/server/api/middleware/project.ts create mode 100644 src/server/api/routers/projects/service/index.ts create mode 100644 src/server/utils/zod.ts diff --git a/package.json b/package.json index a1f8b68..17d00aa 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "fetch-compose-types": "curl -s https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json | json2ts > src/server/docker/compose.d.ts" }, "dependencies": { + "@hookform/resolvers": "^3.3.2", "@mantine/form": "^7.3.2", "@nicktomlin/codemirror-lang-yaml-lite": "^0.0.3", "@prisma/migrate": "^5.7.0", @@ -54,6 +55,7 @@ "node-os-utils": "^1.3.7", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.49.2", "react-icons": "^4.12.0", "react-simple-code-editor": "^0.13.1", "recharts": "^2.10.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e739ff..b535629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@hookform/resolvers': + specifier: ^3.3.2 + version: 3.3.2(react-hook-form@7.49.2) '@mantine/form': specifier: ^7.3.2 version: 7.3.2(react@18.2.0) @@ -119,6 +122,9 @@ dependencies: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.49.2 + version: 7.49.2(react@18.2.0) react-icons: specifier: ^4.12.0 version: 4.12.0(react@18.2.0) @@ -1359,6 +1365,14 @@ packages: resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} dev: false + /@hookform/resolvers@3.3.2(react-hook-form@7.49.2): + resolution: {integrity: sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.49.2(react@18.2.0) + dev: false + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} @@ -6888,6 +6902,15 @@ packages: scheduler: 0.23.0 dev: false + /react-hook-form@7.49.2(react@18.2.0): + resolution: {integrity: sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA==} + engines: {node: '>=18', pnpm: '8'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-icons@4.12.0(react@18.2.0): resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==} peerDependencies: diff --git a/src/app/(dashboard)/project/[id]/_components/CreateService.tsx b/src/app/(dashboard)/project/[id]/_components/CreateService.tsx index 6286f39..f08520f 100644 --- a/src/app/(dashboard)/project/[id]/_components/CreateService.tsx +++ b/src/app/(dashboard)/project/[id]/_components/CreateService.tsx @@ -1,3 +1,115 @@ "use client"; -export function CreateService() {} +import { useQueryClient } from "@tanstack/react-query"; +import { getQueryKey } from "@trpc/react-query"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { z } from "zod"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; +import { Required } from "~/components/ui/required"; +import { useForm } from "~/hooks/forms"; +import { zDockerName } from "~/server/utils/zod"; +import { api } from "~/trpc/react"; +import { useProject } from "../_context/ProjectContext"; + +const formSchema = z.object({ + name: zDockerName, +}); + +export function CreateService() { + const [open, setOpen] = useState(false); + + const queryClient = useQueryClient(); + const project = useProject(); + + const mutate = api.projects.services.create.useMutation({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: getQueryKey( + api.projects.get, + { projectId: project.id }, + "query", + ), + }); + + setOpen(false); + }, + }); + + const form = useForm(formSchema); + + return ( + + + + + + + New Service + + Create a new service for {project.friendlyName}. You will be able to + configure this service before deploying it in the next step. + + + +
+ + mutate.mutate({ name: data.name, projectId: project.id }), + )} + > + ( + + + Service Name + + + + + + This is the name of the service as it will appear in the + Docker Compose file. It must be a valid Docker name. + + + + )} + /> + + + + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/project/[id]/_components/DeployChanges.tsx b/src/app/(dashboard)/project/[id]/_components/DeployChanges.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(dashboard)/project/[id]/_components/ProjectLayout.tsx b/src/app/(dashboard)/project/[id]/_components/ProjectLayout.tsx new file mode 100644 index 0000000..a5006e7 --- /dev/null +++ b/src/app/(dashboard)/project/[id]/_components/ProjectLayout.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Home, Settings2, UploadCloud } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Button } from "~/components/ui/button"; +import { api } from "~/trpc/react"; +import { type RouterOutputs } from "~/trpc/shared"; +import { ProjectContextProvider } from "../_context/ProjectContext"; +import { CreateService } from "./CreateService"; + +export function ProjectLayout(props: { + project: RouterOutputs["projects"]["get"]; + children: React.ReactNode; +}) { + const params = useParams(); + const projectPath = `/project/${params.id as string}`; + + const project = api.projects.get.useQuery( + { projectId: props.project.id }, + { + initialData: props.project, + }, + ); + + const healthy = + project.data.services.filter( + (s) => + s.stats?.ServiceStatus?.RunningTasks == + s.stats?.ServiceStatus?.DesiredTasks, + ).length ?? 0; + + return ( + +

+ {project.data.friendlyName}{" "} + + ({project.data.internalName}) + +

+

+ {/* green dot = healthy, yellow = partial, red = all off */} + 0 + ? "yellow" + : "red" + }-500`} + /> + {healthy}/{project.data.services.length} Healthy Services +

+ +
+ {/* home */} + + + + + {/* deploy changes */} + + + {/* new */} + + + {/* settings */} + +
+ +
+ {/* services */} +
+ {project.data.services.map((service) => ( + +
+
0 + ? "yellow" + : "red" + }-500`} + /> + {service.name} +
+ + ))} + + {project.data.services.length == 0 && ( + No services found. + )} +
+
+ + {props.children} + + ); +} diff --git a/src/app/(dashboard)/project/[id]/_context/ProjectContext.tsx b/src/app/(dashboard)/project/[id]/_context/ProjectContext.tsx new file mode 100644 index 0000000..66652fa --- /dev/null +++ b/src/app/(dashboard)/project/[id]/_context/ProjectContext.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { type RouterOutputs } from "~/trpc/shared"; + +type ProjectContextType = RouterOutputs["projects"]["get"]; + +const ProjectContext = createContext( + {} as unknown as ProjectContextType, +); + +export function ProjectContextProvider(props: { + data: ProjectContextType; + children: React.ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export const useProject = () => { + return useContext(ProjectContext); +}; diff --git a/src/app/(dashboard)/project/[id]/layout.tsx b/src/app/(dashboard)/project/[id]/layout.tsx index 204e997..b6bcbe3 100644 --- a/src/app/(dashboard)/project/[id]/layout.tsx +++ b/src/app/(dashboard)/project/[id]/layout.tsx @@ -1,94 +1,11 @@ -import { Home, Plus, Settings2, UploadCloud } from "lucide-react"; -import Link from "next/link"; -import { Button } from "~/components/ui/button"; import { api } from "~/trpc/server"; +import { ProjectLayout } from "./_components/ProjectLayout"; export default async function ProjectPage(props: { params: { id: string }; children: React.ReactNode; }) { const project = await api.projects.get.query({ projectId: props.params.id }); - const healthy = project.services.filter( - (s) => - s.stats?.ServiceStatus?.RunningTasks == - s.stats?.ServiceStatus?.DesiredTasks, - ).length; - return ( -
-

- {project.friendlyName}{" "} - - ({project.internalName}) - -

-

- {/* green dot = healthy, yellow = partial, red = all off */} - 0 - ? "yellow" - : "red" - }-500`} - /> - {healthy}/{project.services.length} Healthy Services -

- -
- {/* home */} - - - {/* deploy changes */} - - - {/* new */} - - - {/* settings */} - -
- -
- {/* services */} -
- {project.services.map((service) => ( - -
-
0 - ? "yellow" - : "red" - }-500`} - /> - {service.name} -
- - ))} - - {project.services.length == 0 && ( - No services found. - )} -
-
- - {props.children} -
- ); + return {props.children}; } diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..222ff33 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,177 @@ +import type * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; + +import { Label } from "src/components/ui/label"; +import { cn } from "~/utils/utils"; + +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 ( +