From 2b6313a564a294bda2fc8d5379bf242a77038047 Mon Sep 17 00:00:00 2001 From: Tuval Simha Date: Fri, 19 Jul 2024 16:41:41 +0300 Subject: [PATCH] Drop modals from V2: Create project modal (#5228) Co-authored-by: Kamil Kisiela --- .../src/components/layouts/organization.tsx | 244 +++++++++++++++++- .../app/src/components/layouts/project.tsx | 176 ++++++++++++- .../app/src/components/ui/create-target.tsx | 182 ------------- .../components/v2/modals/create-project.tsx | 219 ---------------- .../web/app/src/components/v2/modals/index.ts | 1 - packages/web/app/src/lib/urql-cache.ts | 4 +- .../src/stories/create-project.stories.tsx | 67 +++++ .../app/src/stories/create-target.stories.tsx | 2 +- 8 files changed, 481 insertions(+), 414 deletions(-) delete mode 100644 packages/web/app/src/components/ui/create-target.tsx delete mode 100644 packages/web/app/src/components/v2/modals/create-project.tsx create mode 100644 packages/web/app/src/stories/create-project.stories.tsx diff --git a/packages/web/app/src/components/layouts/organization.tsx b/packages/web/app/src/components/layouts/organization.tsx index c6494a0ec..8aa28be49 100644 --- a/packages/web/app/src/components/layouts/organization.tsx +++ b/packages/web/app/src/components/layouts/organization.tsx @@ -1,11 +1,33 @@ -import { ReactElement, ReactNode } from 'react'; -import { useQuery } from 'urql'; +import { FunctionComponentElement, ReactElement, ReactNode } from 'react'; +import { BlocksIcon, BoxIcon, FoldVerticalIcon } from 'lucide-react'; +import { useForm, UseFormReturn } from 'react-hook-form'; +import { useMutation, useQuery } from 'urql'; +import { z } from 'zod'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useToast } from '@/components/ui/use-toast'; import { UserMenu } from '@/components/ui/user-menu'; -import { CreateProjectModal } from '@/components/v2/modals'; import { Tabs } from '@/components/v2/tabs'; import { env } from '@/env/frontend'; import { graphql, useFragment } from '@/gql'; +import { ProjectType } from '@/gql/graphql'; import { canAccessOrganization, OrganizationAccessScope, @@ -14,7 +36,9 @@ import { import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key'; import { useToggle } from '@/lib/hooks'; import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org'; -import { Link } from '@tanstack/react-router'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Slot } from '@radix-ui/react-slot'; +import { Link, useRouter } from '@tanstack/react-router'; import { ProPlanBilling } from '../organization/billing/ProPlanBillingWarm'; import { RateLimitWarn } from '../organization/billing/RateLimitWarn'; import { HiveLink } from '../ui/hive-link'; @@ -219,3 +243,215 @@ export function OrganizationLayout({ ); } + +export const CreateProjectMutation = graphql(` + mutation CreateProject_CreateProject($input: CreateProjectInput!) { + createProject(input: $input) { + ok { + createdProject { + id + name + cleanId + } + createdTargets { + id + name + cleanId + } + updatedOrganization { + id + } + } + error { + message + inputErrors { + name + buildUrl + validationUrl + } + } + } + } +`); + +const createProjectFormSchema = z.object({ + projectName: z + .string({ + required_error: 'Project name is required', + }) + .min(2, { + message: 'Project name must be at least 2 characters long', + }) + .max(40, { + message: 'Project name must be at most 40 characters long', + }), + projectType: z.nativeEnum(ProjectType, { + required_error: 'Project type is required', + }), +}); + +function ProjectTypeCard(props: { + title: string; + description: string; + type: ProjectType; + icon: FunctionComponentElement<{ className: string }>; +}) { + return ( + + + + + +
+ {props.icon} +
+ {props.title} +

{props.description}

+
+
+
+
+ ); +} + +function CreateProjectModal(props: { + isOpen: boolean; + toggleModalOpen: () => void; + organizationId: string; +}) { + const [_, mutate] = useMutation(CreateProjectMutation); + const router = useRouter(); + const { toast } = useToast(); + + const form = useForm>({ + mode: 'onChange', + resolver: zodResolver(createProjectFormSchema), + defaultValues: { + projectName: '', + projectType: ProjectType.Single, + }, + }); + + async function onSubmit(values: z.infer) { + const { data, error } = await mutate({ + input: { + organization: props.organizationId, + name: values.projectName, + type: values.projectType, + }, + }); + if (data?.createProject.ok) { + props.toggleModalOpen(); + void router.navigate({ + to: '/$organizationId/$projectId', + params: { + organizationId: props.organizationId, + projectId: data.createProject.ok.createdProject.cleanId, + }, + }); + } else if (data?.createProject.error?.inputErrors.name) { + form.setError('projectName', { + message: data?.createProject.error?.inputErrors.name, + }); + } else { + toast({ + variant: 'destructive', + title: 'Failed to create project', + description: error?.message || data?.createProject.error?.message, + }); + } + } + + return ( + + ); +} + +export function CreateProjectModalContent(props: { + isOpen: boolean; + toggleModalOpen: () => void; + form: UseFormReturn>; + onSubmit: (values: z.infer) => void | Promise; +}) { + return ( + + +
+ + + Create a project + + A Hive project represents a GraphQL API running a GraphQL schema. + + +
+ { + return ( + + Name of your project + + + + + + ); + }} + /> + { + return ( + + + } + /> + } + /> + } + /> + + + ); + }} + /> +
+ + + +
+ +
+
+ ); +} diff --git a/packages/web/app/src/components/layouts/project.tsx b/packages/web/app/src/components/layouts/project.tsx index b966b3931..dbf677dce 100644 --- a/packages/web/app/src/components/layouts/project.tsx +++ b/packages/web/app/src/components/layouts/project.tsx @@ -1,15 +1,28 @@ -import { ReactElement, ReactNode } from 'react'; -import { useQuery } from 'urql'; +import { ReactNode } from 'react'; +import { useForm, UseFormReturn } from 'react-hook-form'; +import { useMutation, useQuery } from 'urql'; +import { z } from 'zod'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useToast } from '@/components/ui/use-toast'; import { UserMenu } from '@/components/ui/user-menu'; import { Tabs } from '@/components/v2/tabs'; import { graphql } from '@/gql'; import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project'; import { useToggle } from '@/lib/hooks'; import { useLastVisitedOrganizationWriter } from '@/lib/last-visited-org'; -import { Link } from '@tanstack/react-router'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link, useRouter } from '@tanstack/react-router'; import { ProjectMigrationToast } from '../project/migration-toast'; -import { CreateTargetModal } from '../ui/create-target'; import { HiveLink } from '../ui/hive-link'; import { PlusIcon } from '../ui/icon'; import { ProjectSelector } from './project-selector'; @@ -62,7 +75,7 @@ export function ProjectLayout({ projectId: string; className?: string; children: ReactNode; -}): ReactElement | null { +}) { const [isModalOpen, toggleModalOpen] = useToggle(); const [query] = useQuery({ query: ProjectLayoutQuery, @@ -197,3 +210,156 @@ export function ProjectLayout({ ); } + +export const CreateTarget_CreateTargetMutation = graphql(` + mutation CreateTarget_CreateTarget($input: CreateTargetInput!) { + createTarget(input: $input) { + ok { + selector { + organization + project + target + } + createdTarget { + id + cleanId + name + } + } + error { + message + inputErrors { + name + } + } + } + } +`); + +const createProjectFormSchema = z.object({ + targetName: z + .string({ + required_error: 'Target name is required', + }) + .min(2, { + message: 'Target name must be at least 2 characters long', + }) + .max(50, { + message: 'Target name must be at most 50 characters long', + }), +}); + +function CreateTargetModal(props: { + isOpen: boolean; + toggleModalOpen: () => void; + organizationId: string; + projectId: string; +}) { + const { organizationId, projectId } = props; + const [_, mutate] = useMutation(CreateTarget_CreateTargetMutation); + const router = useRouter(); + const { toast } = useToast(); + + const form = useForm>({ + mode: 'onChange', + resolver: zodResolver(createProjectFormSchema), + defaultValues: { + targetName: '', + }, + }); + + async function onSubmit(values: z.infer) { + const { data, error } = await mutate({ + input: { + project: props.projectId, + organization: props.organizationId, + name: values.targetName, + }, + }); + + if (data?.createTarget.ok) { + props.toggleModalOpen(); + void router.navigate({ + to: '/$organizationId/$projectId/$targetId', + params: { + organizationId, + projectId, + targetId: data.createTarget.ok.createdTarget.cleanId, + }, + }); + toast({ + variant: 'default', + title: 'Target created', + description: `Your target "${data.createTarget.ok.createdTarget.name}" has been created`, + }); + } else if (data?.createTarget.error?.inputErrors.name) { + form.setError('targetName', { + message: data?.createTarget.error?.inputErrors.name, + }); + } else { + toast({ + variant: 'destructive', + title: 'Failed to create target', + description: error?.message || data?.createTarget.error?.message, + }); + } + } + + return ( + + ); +} + +export function CreateTargetModalContent(props: { + isOpen: boolean; + toggleModalOpen: () => void; + onSubmit: (values: z.infer) => void | Promise; + form: UseFormReturn>; +}) { + return ( + + +
+ + + Create a new target + + A project is built on top of Targets, which are just your environments. + + +
+ { + return ( + + + + + + + ); + }} + /> +
+ + + +
+ +
+
+ ); +} diff --git a/packages/web/app/src/components/ui/create-target.tsx b/packages/web/app/src/components/ui/create-target.tsx deleted file mode 100644 index aae39401d..000000000 --- a/packages/web/app/src/components/ui/create-target.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { ReactElement } from 'react'; -import { useForm, UseFormReturn } from 'react-hook-form'; -import { useMutation } from 'urql'; -import { z } from 'zod'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { useToast } from '@/components/ui/use-toast'; -import { graphql } from '@/gql'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useRouter } from '@tanstack/react-router'; - -export const CreateTarget_CreateTargetMutation = graphql(` - mutation CreateTarget_CreateTarget($input: CreateTargetInput!) { - createTarget(input: $input) { - ok { - selector { - organization - project - target - } - createdTarget { - id - cleanId - name - } - } - error { - message - inputErrors { - name - } - } - } - } -`); - -const formSchema = z.object({ - targetName: z - .string({ - required_error: 'Target name is required', - }) - .min(2, { - message: 'Target name must be at least 2 characters long', - }) - .max(50, { - message: 'Target name must be at most 50 characters long', - }) - .regex( - /^([a-z]|[0-9]|\s|\.|,|_|-|\/|&)+$/i, - 'Target name restricted to alphanumerical characters, spaces and . , _ - / &', - ), -}); - -type CreateTargetModalProps = { - isOpen: boolean; - toggleModalOpen: () => void; - organizationId: string; - projectId: string; -}; - -export const CreateTargetModal = ({ ...props }: CreateTargetModalProps): ReactElement => { - const { organizationId, projectId } = props; - const [_, mutate] = useMutation(CreateTarget_CreateTargetMutation); - const router = useRouter(); - const { toast } = useToast(); - - const form = useForm>({ - mode: 'onChange', - resolver: zodResolver(formSchema), - defaultValues: { - targetName: '', - }, - }); - - async function onSubmit(values: z.infer) { - const { data, error } = await mutate({ - input: { - project: props.projectId, - organization: props.organizationId, - name: values.targetName, - }, - }); - - if (data?.createTarget.ok) { - props.toggleModalOpen(); - void router.navigate({ - to: '/$organizationId/$projectId/$targetId', - params: { - organizationId, - projectId, - targetId: data.createTarget.ok.createdTarget.cleanId, - }, - }); - toast({ - variant: 'default', - title: 'Target created', - description: `Your target "${data.createTarget.ok.createdTarget.name}" has been created`, - }); - } else if (data?.createTarget.error?.inputErrors.name) { - form.setError('targetName', { - message: data?.createTarget.error?.inputErrors.name, - }); - } else { - toast({ - variant: 'destructive', - title: 'Failed to create target', - description: error?.message || data?.createTarget.error?.message, - }); - } - } - - return ( - - ); -}; - -type CreateTargetModalContentProps = { - isOpen: boolean; - toggleModalOpen: () => void; - onSubmit: (values: z.infer) => void | Promise; - form: UseFormReturn>; -}; - -export const CreateTargetModalContent = ({ - ...props -}: CreateTargetModalContentProps): ReactElement => { - return ( - - -
- - - Create a new target - - A project is built on top of Targets, which are just your environments. - - -
- { - return ( - - - - - - - ); - }} - /> -
- - - -
- -
-
- ); -}; diff --git a/packages/web/app/src/components/v2/modals/create-project.tsx b/packages/web/app/src/components/v2/modals/create-project.tsx deleted file mode 100644 index c416c029e..000000000 --- a/packages/web/app/src/components/v2/modals/create-project.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { FunctionComponentElement } from 'react'; -import { BlocksIcon, BoxIcon, FoldVerticalIcon } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { useMutation } from 'urql'; -import { z } from 'zod'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { useToast } from '@/components/ui/use-toast'; -import { graphql } from '@/gql'; -import { ProjectType } from '@/gql/graphql'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Slot } from '@radix-ui/react-slot'; -import { useRouter } from '@tanstack/react-router'; - -export const CreateProjectMutation = graphql(` - mutation CreateProject_CreateProject($input: CreateProjectInput!) { - createProject(input: $input) { - ok { - createdProject { - id - name - cleanId - } - createdTargets { - id - name - cleanId - } - updatedOrganization { - id - } - } - error { - message - inputErrors { - name - buildUrl - validationUrl - } - } - } - } -`); - -const formSchema = z.object({ - name: z.string().min(1, { - message: 'Project name is required', - }), - type: z.nativeEnum(ProjectType, { - message: 'Project type is required', - }), -}); - -function ProjectTypeCard(props: { - title: string; - description: string; - type: ProjectType; - icon: FunctionComponentElement<{ className: string }>; -}) { - return ( - - - - - -
- {props.icon} -
- {props.title} -

{props.description}

-
-
-
-
- ); -} - -export const CreateProjectModal = (props: { - isOpen: boolean; - toggleModalOpen: () => void; - organizationId: string; -}) => { - const { isOpen, toggleModalOpen } = props; - const [_, mutate] = useMutation(CreateProjectMutation); - const router = useRouter(); - const { toast } = useToast(); - - const form = useForm>({ - mode: 'onChange', - resolver: zodResolver(formSchema), - defaultValues: { - name: '', - type: ProjectType.Single, - }, - }); - - async function onSubmit(values: z.infer) { - const { data, error } = await mutate({ - input: { - organization: props.organizationId, - ...values, - }, - }); - if (data?.createProject.ok) { - toggleModalOpen(); - void router.navigate({ - to: '/$organizationId/$projectId', - params: { - organizationId: props.organizationId, - projectId: data.createProject.ok.createdProject.cleanId, - }, - }); - } else if (data?.createProject.error?.inputErrors.name) { - form.setError('name', { - message: data?.createProject.error?.inputErrors.name, - }); - } else { - toast({ - variant: 'destructive', - title: 'Failed to create project', - description: error?.message || data?.createProject.error?.message, - }); - } - } - - return ( - - -
- - - Create a project - - A Hive project represents a GraphQL API running a GraphQL schema. - - -
- { - return ( - - Name of your project - - - - - - ); - }} - /> - { - return ( - - - } - /> - } - /> - } - /> - - - ); - }} - /> -
- - - -
- -
-
- ); -}; diff --git a/packages/web/app/src/components/v2/modals/index.ts b/packages/web/app/src/components/v2/modals/index.ts index 88257e223..5a057b31a 100644 --- a/packages/web/app/src/components/v2/modals/index.ts +++ b/packages/web/app/src/components/v2/modals/index.ts @@ -1,7 +1,6 @@ export { ChangePermissionsModal } from './change-permissions'; export { ConnectSchemaModal } from './connect-schema'; export { CreateAccessTokenModal } from './create-access-token'; -export { CreateProjectModal } from './create-project'; export { DeleteOrganizationModal } from './delete-organization'; export { DeleteProjectModal } from './delete-project'; export { DeleteTargetModal } from './delete-target'; diff --git a/packages/web/app/src/lib/urql-cache.ts b/packages/web/app/src/lib/urql-cache.ts index 6e919ca80..0a41557f8 100644 --- a/packages/web/app/src/lib/urql-cache.ts +++ b/packages/web/app/src/lib/urql-cache.ts @@ -1,6 +1,8 @@ import { DocumentNode, Kind } from 'graphql'; import { produce } from 'immer'; import { TypedDocumentNode } from 'urql'; +import type { CreateProjectMutation } from '@/components/layouts/organization'; +import type { CreateTarget_CreateTargetMutation } from '@/components/layouts/project'; import type { CreateAlertModal_AddAlertMutation } from '@/components/project/alerts/create-alert'; import type { CreateChannel_AddAlertChannelMutation } from '@/components/project/alerts/create-channel'; import type { DeleteAlertsButton_DeleteAlertsMutation } from '@/components/project/alerts/delete-alerts-button'; @@ -8,9 +10,7 @@ import type { DeleteChannelsButton_DeleteChannelsMutation } from '@/components/p import type { CreateOperationMutationType } from '@/components/target/laboratory/create-operation-modal'; import type { DeleteCollectionMutationType } from '@/components/target/laboratory/delete-collection-modal'; import type { DeleteOperationMutationType } from '@/components/target/laboratory/delete-operation-modal'; -import { CreateTarget_CreateTargetMutation } from '@/components/ui/create-target'; import type { CreateAccessToken_CreateTokenMutation } from '@/components/v2/modals/create-access-token'; -import type { CreateProjectMutation } from '@/components/v2/modals/create-project'; import type { DeleteOrganizationDocument } from '@/components/v2/modals/delete-organization'; import { type DeleteProjectMutation } from '@/components/v2/modals/delete-project'; import { type DeleteTargetMutation } from '@/components/v2/modals/delete-target'; diff --git a/packages/web/app/src/stories/create-project.stories.tsx b/packages/web/app/src/stories/create-project.stories.tsx new file mode 100644 index 000000000..f715454c7 --- /dev/null +++ b/packages/web/app/src/stories/create-project.stories.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { CreateProjectModalContent } from '@/components/layouts/organization'; +import { Button } from '@/components/ui/button'; +import { ProjectType } from '@/gql/graphql'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Modals/Create Project Modal', + component: CreateProjectModalContent, +}; + +export default meta; +type Story = StoryObj; + +const formSchema = z.object({ + projectName: z + .string({ + required_error: 'Project name is required', + }) + .min(2, { + message: 'Project name must be at least 2 characters long', + }) + .max(50, { + message: 'Project name must be at most 50 characters long', + }) + .regex( + /^([a-z]|[0-9]|\s|\.|,|_|-|\/|&)+$/i, + 'Project name restricted to alphanumerical characters, spaces and . , _ - / &', + ), + projectType: z.nativeEnum(ProjectType, { + required_error: 'Project type is required', + }), +}); + +export const Default: Story = { + render: () => { + const form = useForm>({ + mode: 'onChange', + resolver: zodResolver(formSchema), + defaultValues: { + projectName: '', + projectType: ProjectType.Single, + }, + }); + + const [openModal, setOpenModal] = useState(false); + const toggleModalOpen = () => setOpenModal(!openModal); + + return ( + <> + + {openModal && ( + console.log('Submit')} + key="create-project-modal" + /> + )} + + ); + }, +}; diff --git a/packages/web/app/src/stories/create-target.stories.tsx b/packages/web/app/src/stories/create-target.stories.tsx index e43e583d6..3f93c9132 100644 --- a/packages/web/app/src/stories/create-target.stories.tsx +++ b/packages/web/app/src/stories/create-target.stories.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { CreateTargetModalContent } from '@/components/layouts/project'; import { Button } from '@/components/ui/button'; -import { CreateTargetModalContent } from '@/components/ui/create-target'; import { zodResolver } from '@hookform/resolvers/zod'; import { Meta, StoryObj } from '@storybook/react';