mirror of
https://github.com/graphql-hive/console
synced 2026-05-24 01:28:32 +00:00
Drop modals from V2: Create project modal (#5228)
Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com>
This commit is contained in:
parent
479397e570
commit
2b6313a564
8 changed files with 481 additions and 414 deletions
|
|
@ -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 (
|
||||
<FormItem>
|
||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary cursor-pointer">
|
||||
<FormControl>
|
||||
<RadioGroupItem value={props.type} className="sr-only" />
|
||||
</FormControl>
|
||||
<div className="border-muted hover:border-accent hover:bg-accent flex items-center gap-4 rounded-md border-2 p-4">
|
||||
<Slot className="size-8 text-gray-400">{props.icon}</Slot>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{props.title}</span>
|
||||
<p className="text-sm text-gray-400">{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectModal(props: {
|
||||
isOpen: boolean;
|
||||
toggleModalOpen: () => void;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const [_, mutate] = useMutation(CreateProjectMutation);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<z.infer<typeof createProjectFormSchema>>({
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(createProjectFormSchema),
|
||||
defaultValues: {
|
||||
projectName: '',
|
||||
projectType: ProjectType.Single,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof createProjectFormSchema>) {
|
||||
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 (
|
||||
<CreateProjectModalContent
|
||||
isOpen={props.isOpen}
|
||||
toggleModalOpen={props.toggleModalOpen}
|
||||
form={form}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateProjectModalContent(props: {
|
||||
isOpen: boolean;
|
||||
toggleModalOpen: () => void;
|
||||
form: UseFormReturn<z.infer<typeof createProjectFormSchema>>;
|
||||
onSubmit: (values: z.infer<typeof createProjectFormSchema>) => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.toggleModalOpen}>
|
||||
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
|
||||
<Form {...props.form}>
|
||||
<form className="space-y-8" onSubmit={props.form.handleSubmit(props.onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a project</DialogTitle>
|
||||
<DialogDescription>
|
||||
A Hive <b>project</b> represents a <b>GraphQL API</b> running a GraphQL schema.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Name of your project</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My GraphQL API" autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="projectType"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="pt-2"
|
||||
>
|
||||
<ProjectTypeCard
|
||||
type={ProjectType.Single}
|
||||
title="Single"
|
||||
description="Monolithic GraphQL schema developed as a standalone"
|
||||
icon={<BoxIcon />}
|
||||
/>
|
||||
<ProjectTypeCard
|
||||
type={ProjectType.Federation}
|
||||
title="Federation"
|
||||
description="Project developed according to Apollo Federation specification"
|
||||
icon={<BlocksIcon />}
|
||||
/>
|
||||
<ProjectTypeCard
|
||||
type={ProjectType.Stitching}
|
||||
title="Stitching"
|
||||
description="Project that stitches together multiple GraphQL APIs"
|
||||
icon={<FoldVerticalIcon />}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={props.form.formState.isSubmitting || !props.form.formState.isValid}
|
||||
>
|
||||
{props.form.formState.isSubmitting ? 'Submitting...' : 'Create Project'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<z.infer<typeof createProjectFormSchema>>({
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(createProjectFormSchema),
|
||||
defaultValues: {
|
||||
targetName: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof createProjectFormSchema>) {
|
||||
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 (
|
||||
<CreateTargetModalContent
|
||||
form={form}
|
||||
isOpen={props.isOpen}
|
||||
onSubmit={onSubmit}
|
||||
toggleModalOpen={props.toggleModalOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateTargetModalContent(props: {
|
||||
isOpen: boolean;
|
||||
toggleModalOpen: () => void;
|
||||
onSubmit: (values: z.infer<typeof createProjectFormSchema>) => void | Promise<void>;
|
||||
form: UseFormReturn<z.infer<typeof createProjectFormSchema>>;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.toggleModalOpen}>
|
||||
<DialogContent className="container w-4/5 max-w-[520px] md:w-3/5">
|
||||
<Form {...props.form}>
|
||||
<form className="space-y-8" onSubmit={props.form.handleSubmit(props.onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new target</DialogTitle>
|
||||
<DialogDescription>
|
||||
A project is built on top of <b>Targets</b>, which are just your environments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="targetName"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder="Target name" autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={props.form.formState.isSubmitting || !props.form.formState.isValid}
|
||||
>
|
||||
{props.form.formState.isSubmitting ? 'Submitting...' : 'Create Target'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<z.infer<typeof formSchema>>({
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
targetName: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
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 (
|
||||
<CreateTargetModalContent
|
||||
form={form}
|
||||
isOpen={props.isOpen}
|
||||
onSubmit={onSubmit}
|
||||
toggleModalOpen={props.toggleModalOpen}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type CreateTargetModalContentProps = {
|
||||
isOpen: boolean;
|
||||
toggleModalOpen: () => void;
|
||||
onSubmit: (values: z.infer<typeof formSchema>) => void | Promise<void>;
|
||||
form: UseFormReturn<z.infer<typeof formSchema>>;
|
||||
};
|
||||
|
||||
export const CreateTargetModalContent = ({
|
||||
...props
|
||||
}: CreateTargetModalContentProps): ReactElement => {
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.toggleModalOpen}>
|
||||
<DialogContent className="container w-4/5 max-w-[520px] md:w-3/5">
|
||||
<Form {...props.form}>
|
||||
<form className="space-y-8" onSubmit={props.form.handleSubmit(props.onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new target</DialogTitle>
|
||||
<DialogDescription>
|
||||
A project is built on top of <b>Targets</b>, which are just your environments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="targetName"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder="Target name" autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={props.form.formState.isSubmitting || !props.form.formState.isValid}
|
||||
>
|
||||
{props.form.formState.isSubmitting ? 'Submitting...' : 'Create Target'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<FormItem>
|
||||
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
|
||||
<FormControl>
|
||||
<RadioGroupItem value={props.type} className="sr-only" />
|
||||
</FormControl>
|
||||
<div className="border-muted hover:border-accent hover:bg-accent flex items-center gap-4 rounded-md border-2 p-4">
|
||||
<Slot className="size-8 text-gray-400">{props.icon}</Slot>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{props.title}</span>
|
||||
<p className="text-sm text-gray-400">{props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
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<z.infer<typeof formSchema>>({
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
type: ProjectType.Single,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={toggleModalOpen}>
|
||||
<DialogContent className="absolute w-[600px] max-w-none">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a project</DialogTitle>
|
||||
<DialogDescription>
|
||||
A Hive <b>project</b> represents a <b>GraphQL API</b> running a GraphQL schema.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Name of your project</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My GraphQL API" autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="pt-2"
|
||||
>
|
||||
<ProjectTypeCard
|
||||
type={ProjectType.Single}
|
||||
title="Single"
|
||||
description="Monolithic GraphQL schema developed as a standalone"
|
||||
icon={<BoxIcon />}
|
||||
/>
|
||||
<ProjectTypeCard
|
||||
type={ProjectType.Federation}
|
||||
title="Federation"
|
||||
description="Project developed according to Apollo Federation specification"
|
||||
icon={<BlocksIcon />}
|
||||
/>
|
||||
<ProjectTypeCard
|
||||
type={ProjectType.Stitching}
|
||||
title="Stitching"
|
||||
description="Project that stitches together multiple GraphQL APIs"
|
||||
icon={<FoldVerticalIcon />}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting || !form.formState.isValid}
|
||||
>
|
||||
{form.formState.isSubmitting ? 'Submitting...' : 'Create Project'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
67
packages/web/app/src/stories/create-project.stories.tsx
Normal file
67
packages/web/app/src/stories/create-project.stories.tsx
Normal file
|
|
@ -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<typeof CreateProjectModalContent> = {
|
||||
title: 'Modals/Create Project Modal',
|
||||
component: CreateProjectModalContent,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CreateProjectModalContent>;
|
||||
|
||||
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<z.infer<typeof formSchema>>({
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
projectName: '',
|
||||
projectType: ProjectType.Single,
|
||||
},
|
||||
});
|
||||
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const toggleModalOpen = () => setOpenModal(!openModal);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={toggleModalOpen}>Open Modal</Button>
|
||||
{openModal && (
|
||||
<CreateProjectModalContent
|
||||
isOpen={openModal}
|
||||
toggleModalOpen={toggleModalOpen}
|
||||
form={form}
|
||||
onSubmit={() => console.log('Submit')}
|
||||
key="create-project-modal"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue