Add GQL mutation for updating project data in general settings tab (#41)

* Use update project gql client method in UI

* Handle save project button based on form change

* Fix import order

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-01-25 16:38:40 +05:30 committed by Ashwin Phatak
parent 61120ac44a
commit 5310d7c7d0
13 changed files with 301 additions and 166 deletions

View File

@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useCombobox } from 'downshift'; import { useCombobox } from 'downshift';
import { Project } from 'gql-client';
import { import {
List, List,
@ -9,8 +10,6 @@ import {
Typography, Typography,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import { Project } from 'gql-client';
import SearchBar from '../SearchBar'; import SearchBar from '../SearchBar';
import { useGQLClient } from '../../context/GQLClientContext'; import { useGQLClient } from '../../context/GQLClientContext';

View File

@ -1,4 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Project } from 'gql-client';
import { Typography, Button, Chip } from '@material-tailwind/react'; import { Typography, Button, Chip } from '@material-tailwind/react';
@ -8,17 +9,19 @@ import { ProjectDetails } from '../../../types/project';
import { relativeTimeMs } from '../../../utils/time'; import { relativeTimeMs } from '../../../utils/time';
interface OverviewProps { interface OverviewProps {
project: ProjectDetails; project: Project;
organizationProject: ProjectDetails;
} }
const OverviewTabPanel = ({ project }: OverviewProps) => { const OverviewTabPanel = ({ project, organizationProject }: OverviewProps) => {
// TODO: Fetch current deployment
const currentDeploymentTitle = useMemo(() => { const currentDeploymentTitle = useMemo(() => {
const deployment = project.deployments.find((deployment) => { const deployment = organizationProject?.deployments.find((deployment) => {
return deployment.isCurrent === true; return deployment.isCurrent === true;
}); });
return deployment?.title; return deployment?.title;
}, []); }, [organizationProject]);
return ( return (
<div className="grid grid-cols-5"> <div className="grid grid-cols-5">
@ -28,14 +31,14 @@ const OverviewTabPanel = ({ project }: OverviewProps) => {
<div className="grow"> <div className="grow">
<Typography>{project.name}</Typography> <Typography>{project.name}</Typography>
<Typography variant="small" color="gray"> <Typography variant="small" color="gray">
{project.url} {organizationProject.url}
</Typography> </Typography>
</div> </div>
</div> </div>
<div className="flex justify-between p-2 text-sm items-center"> <div className="flex justify-between p-2 text-sm items-center">
<div> <div>
^ Domain ^ Domain
{!project.domain && ( {!organizationProject.domain && (
<Chip <Chip
className="normal-case ml-6 bg-[#FED7AA] text-[#EA580C] inline font-normal" className="normal-case ml-6 bg-[#FED7AA] text-[#EA580C] inline font-normal"
size="sm" size="sm"
@ -44,8 +47,8 @@ const OverviewTabPanel = ({ project }: OverviewProps) => {
/> />
)} )}
</div> </div>
{project.domain ? ( {organizationProject.domain ? (
<p>{project.domain}</p> <p>{organizationProject.domain}</p>
) : ( ) : (
<Button className="normal-case rounded-full" color="blue" size="sm"> <Button className="normal-case rounded-full" color="blue" size="sm">
Setup Setup
@ -54,7 +57,7 @@ const OverviewTabPanel = ({ project }: OverviewProps) => {
</div> </div>
<div className="flex justify-between p-2 text-sm"> <div className="flex justify-between p-2 text-sm">
<p>^ Source</p> <p>^ Source</p>
<p>{project.source}</p> <p>{organizationProject.source}</p>
</div> </div>
<div className="flex justify-between p-2 text-sm"> <div className="flex justify-between p-2 text-sm">
<p>^ Deployment</p> <p>^ Deployment</p>
@ -63,7 +66,7 @@ const OverviewTabPanel = ({ project }: OverviewProps) => {
<div className="flex justify-between p-2 text-sm"> <div className="flex justify-between p-2 text-sm">
<p>^ Created</p> <p>^ Created</p>
<p> <p>
{relativeTimeMs(project.createdAt)} by ^ {project.createdBy} {relativeTimeMs(project.createdAt)} by ^ {project.owner.name}
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
import { Project } from 'gql-client';
import OverviewTabPanel from './OverviewTabPanel'; import OverviewTabPanel from './OverviewTabPanel';
import DeploymentsTabPanel from './DeploymentsTabPanel'; import DeploymentsTabPanel from './DeploymentsTabPanel';
@ -7,7 +8,9 @@ import { ProjectDetails } from '../../../types/project';
import SettingsTabPanel from './SettingsTabPanel'; import SettingsTabPanel from './SettingsTabPanel';
interface ProjectTabsProps { interface ProjectTabsProps {
project: ProjectDetails; project: Project;
organizationProject: ProjectDetails;
onUpdate: () => Promise<void>;
} }
const Database = () => ( const Database = () => (
@ -28,7 +31,11 @@ const Integrations = () => (
</div> </div>
); );
const ProjectTabs = ({ project }: ProjectTabsProps) => { const ProjectTabs = ({
project,
onUpdate,
organizationProject,
}: ProjectTabsProps) => {
return ( return (
<Tabs <Tabs
selectedTabClassName={ selectedTabClassName={
@ -43,7 +50,10 @@ const ProjectTabs = ({ project }: ProjectTabsProps) => {
<Tab className={'p-2 cursor-pointer'}>Settings</Tab> <Tab className={'p-2 cursor-pointer'}>Settings</Tab>
</TabList> </TabList>
<TabPanel> <TabPanel>
<OverviewTabPanel project={project} /> <OverviewTabPanel
project={project}
organizationProject={organizationProject}
/>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<DeploymentsTabPanel projectId={project.id} /> <DeploymentsTabPanel projectId={project.id} />
@ -55,7 +65,7 @@ const ProjectTabs = ({ project }: ProjectTabsProps) => {
<Integrations /> <Integrations />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<SettingsTabPanel /> <SettingsTabPanel project={project} onUpdate={onUpdate} />
</TabPanel> </TabPanel>
</Tabs> </Tabs>
); );

View File

@ -1,4 +1,6 @@
import React, { createElement } from 'react'; import React, { createElement } from 'react';
import { Project } from 'gql-client';
import { import {
Tabs, Tabs,
TabsHeader, TabsHeader,
@ -46,7 +48,13 @@ const tabsData = [
}, },
]; ];
const SettingsTabPanel = () => { const SettingsTabPanel = ({
project,
onUpdate,
}: {
project: Project;
onUpdate: () => Promise<void>;
}) => {
return ( return (
<> <>
<Tabs <Tabs
@ -72,7 +80,10 @@ const SettingsTabPanel = () => {
<TabsBody className="col-span-2"> <TabsBody className="col-span-2">
{tabsData.map(({ value, component }) => ( {tabsData.map(({ value, component }) => (
<TabPanel key={value} value={value} className="p-2"> <TabPanel key={value} value={value} className="p-2">
{createElement(component)} {createElement(component, {
project: project,
onUpdate: onUpdate,
})}
</TabPanel> </TabPanel>
))} ))}
</TabsBody> </TabsBody>

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { IconButton, Input, Typography } from '@material-tailwind/react';
import { EnvironmentVariable } from 'gql-client'; import { EnvironmentVariable } from 'gql-client';
import { IconButton, Input, Typography } from '@material-tailwind/react';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
const ShowPasswordIcon = ({ const ShowPasswordIcon = ({

View File

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Environment, EnvironmentVariable } from 'gql-client';
import { import {
Typography, Typography,
@ -12,8 +13,6 @@ import {
Chip, Chip,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import { Environment, EnvironmentVariable } from 'gql-client';
import AddEnvironmentVariableRow from './AddEnvironmentVariableRow'; import AddEnvironmentVariableRow from './AddEnvironmentVariableRow';
import DisplayEnvironmentVariables from './DisplayEnvironmentVariables'; import DisplayEnvironmentVariables from './DisplayEnvironmentVariables';
import HorizontalLine from '../../../HorizontalLine'; import HorizontalLine from '../../../HorizontalLine';

View File

@ -1,7 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Project } from 'gql-client';
import { import {
Button, Button,
@ -13,7 +14,7 @@ import {
import DeleteProjectDialog from './DeleteProjectDialog'; import DeleteProjectDialog from './DeleteProjectDialog';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
import { ProjectSearchOutletContext } from '../../../../types/project'; import { useGQLClient } from '../../../../context/GQLClientContext';
const TEAMS = ['Airfoil']; const TEAMS = ['Airfoil'];
const DEFAULT_SELECT_TEAM = undefined; const DEFAULT_SELECT_TEAM = undefined;
@ -32,13 +33,14 @@ const CopyIcon = ({ value }: { value: string }) => {
); );
}; };
const GeneralTabPanel = () => { const GeneralTabPanel = ({
const { id } = useParams(); project,
const { projects } = useOutletContext<ProjectSearchOutletContext>(); onUpdate,
}: {
const currentProject = useMemo(() => { project: Project;
return projects.find((project: any) => project.id === id); onUpdate: () => Promise<void>;
}, [id]); }) => {
const client = useGQLClient();
const { const {
handleSubmit: handleTransfer, handleSubmit: handleTransfer,
@ -58,134 +60,155 @@ const GeneralTabPanel = () => {
const handleDeleteProjectDialog = () => const handleDeleteProjectDialog = () =>
setOpenDeleteDialog(!openDeleteDialog); setOpenDeleteDialog(!openDeleteDialog);
const { handleSubmit, register } = useForm({ const {
handleSubmit,
register,
reset,
formState: updateProjectFormState,
} = useForm({
defaultValues: { defaultValues: {
appName: currentProject?.name, appName: project.name,
description: currentProject?.description, description: project.description,
}, },
}); });
useEffect(() => {
reset({ appName: project.name, description: project.description });
}, [project]);
return ( return (
<> <>
{currentProject && ( <form
<> onSubmit={handleSubmit(async ({ appName, description }) => {
<form onSubmit={handleSubmit(() => {})}> const { updateProject } = await client.updateProject(project.id, {
<Typography variant="h6">Project info</Typography> name: appName,
<Typography variant="small" className="font-medium text-gray-800"> description,
App name });
</Typography> if (updateProject) {
<Input await onUpdate();
variant="outlined" }
// TODO: Debug issue: https://github.com/creativetimofficial/material-tailwind/issues/427 })}
crossOrigin={undefined} >
size="md" <Typography variant="h6">Project info</Typography>
{...register('appName')} <Typography variant="small" className="font-medium text-gray-800">
/> App name
<Typography variant="small" className="font-medium text-gray-800"> </Typography>
Description (Optional) <Input
</Typography> variant="outlined"
<Input // TODO: Debug issue: https://github.com/creativetimofficial/material-tailwind/issues/427
variant="outlined" crossOrigin={undefined}
crossOrigin={undefined} size="md"
size="md" {...register('appName')}
{...register('description')} />
/> <Typography variant="small" className="font-medium text-gray-800">
<Typography variant="small" className="font-medium text-gray-800"> Description (Optional)
Project ID </Typography>
</Typography> <Input
<Input variant="outlined"
crossOrigin={undefined} crossOrigin={undefined}
variant="outlined" size="md"
value={currentProject.id} {...register('description')}
size="md" />
disabled <Typography variant="small" className="font-medium text-gray-800">
icon={<CopyIcon value={currentProject.id} />} Project ID
/> </Typography>
<Button type="submit" variant="gradient" size="sm" className="mt-1"> <Input
Save crossOrigin={undefined}
</Button> variant="outlined"
</form> value={project.id}
<div className="mb-1"> size="md"
<Typography variant="h6">Transfer project</Typography> disabled
<Typography variant="small"> icon={<CopyIcon value={project.id} />}
Transfer this app to your personal account or a team you are a />
member of. <Button
<Link to="" className="text-blue-500"> type="submit"
Learn more variant="gradient"
</Link> size="sm"
</Typography> className="mt-1"
<form disabled={!updateProjectFormState.isDirty}
onSubmit={handleTransfer(() => { >
handleTransferProjectDialog(); Save
})} </Button>
> </form>
<Typography variant="small" className="font-medium text-gray-800"> <div className="mb-1">
Choose team <Typography variant="h6">Transfer project</Typography>
</Typography> <Typography variant="small">
<Controller Transfer this app to your personal account or a team you are a member
name="team" of.
rules={{ required: 'This field is required' }} <Link to="" className="text-blue-500">
control={control} Learn more
render={({ field }) => ( </Link>
<Select </Typography>
{...field} <form
// TODO: Implement placeholder for select onSubmit={handleTransfer(() => {
label={!field.value ? 'Select an account / team' : ''} handleTransferProjectDialog();
> })}
{TEAMS.map((team, key) => ( >
<Option key={key} value={team}> <Typography variant="small" className="font-medium text-gray-800">
^ {team} Choose team
</Option> </Typography>
))} <Controller
</Select> name="team"
)} rules={{ required: 'This field is required' }}
/> control={control}
<Button render={({ field }) => (
variant="gradient" <Select
size="sm" {...field}
className="mt-1" // TODO: Implement placeholder for select
disabled={!formState.isValid} label={!field.value ? 'Select an account / team' : ''}
type="submit"
> >
Transfer {TEAMS.map((team, key) => (
</Button> <Option key={key} value={team}>
</form> ^ {team}
<ConfirmDialog </Option>
dialogTitle="Transfer project" ))}
handleOpen={handleTransferProjectDialog} </Select>
open={openTransferDialog} )}
confirmButtonTitle="Yes, Confirm transfer" />
handleConfirm={handleTransferProjectDialog} <Button
color="blue" variant="gradient"
> size="sm"
<Typography variant="small"> className="mt-1"
Upon confirmation, your project nextjs-boilerplate will be disabled={!formState.isValid}
transferred from saugat to Airfoil. type="submit"
</Typography> >
</ConfirmDialog> Transfer
</div> </Button>
<div className="mb-1"> </form>
<Typography variant="h6">Delete project</Typography> <ConfirmDialog
<Typography variant="small"> dialogTitle="Transfer project"
The project will be permanently deleted, including its deployments handleOpen={handleTransferProjectDialog}
and domains. This action is irreversible and can not be undone. open={openTransferDialog}
</Typography> confirmButtonTitle="Yes, Confirm transfer"
<Button handleConfirm={handleTransferProjectDialog}
variant="gradient" color="blue"
size="sm" >
color="red" <Typography variant="small">
onClick={handleDeleteProjectDialog} Upon confirmation, your project nextjs-boilerplate will be
> transferred from saugat to Airfoil.
^ Delete project </Typography>
</Button> </ConfirmDialog>
<DeleteProjectDialog </div>
handleOpen={handleDeleteProjectDialog} <div className="mb-1">
open={openDeleteDialog} <Typography variant="h6">Delete project</Typography>
project={{ name: 'Iglootools' }} <Typography variant="small">
/> The project will be permanently deleted, including its deployments and
</div> domains. This action is irreversible and can not be undone.
</> </Typography>
)} <Button
variant="gradient"
size="sm"
color="red"
onClick={handleDeleteProjectDialog}
>
^ Delete project
</Button>
<DeleteProjectDialog
handleOpen={handleDeleteProjectDialog}
open={openDeleteDialog}
project={{ name: 'Iglootools' }}
/>
</div>
</> </>
); );
}; };

View File

@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { Environment } from 'gql-client'; import { Environment } from 'gql-client';
import HorizontalLine from '../components/HorizontalLine';
import { IconButton, Typography } from '@material-tailwind/react'; import { IconButton, Typography } from '@material-tailwind/react';
import HorizontalLine from '../components/HorizontalLine';
import ProjectSearchBar from '../components/projects/ProjectSearchBar'; import ProjectSearchBar from '../components/projects/ProjectSearchBar';
import { useGQLClient } from '../context/GQLClientContext'; import { useGQLClient } from '../context/GQLClientContext';
import { ProjectDetails } from '../types/project'; import { ProjectDetails } from '../types/project';

View File

@ -1,27 +1,49 @@
import React, { useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import { Project as ProjectType } from 'gql-client';
import { Button, Typography } from '@material-tailwind/react'; import { Button, Typography } from '@material-tailwind/react';
import HorizontalLine from '../../components/HorizontalLine'; import HorizontalLine from '../../components/HorizontalLine';
import ProjectTabs from '../../components/projects/project/ProjectTabs'; import ProjectTabs from '../../components/projects/project/ProjectTabs';
import { useGQLClient } from '../../context/GQLClientContext';
import { ProjectSearchOutletContext } from '../../types/project'; import { ProjectSearchOutletContext } from '../../types/project';
const Project = () => { const Project = () => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const client = useGQLClient();
const { projects } = useOutletContext<ProjectSearchOutletContext>(); const [project, setProject] = useState<ProjectType | null>(null);
const project = useMemo(() => { const fetchProject = useCallback(async (id: string | undefined) => {
return projects.find((project) => { if (id) {
const { project } = await client.getProject(id);
setProject(project);
}
}, []);
useEffect(() => {
fetchProject(id);
}, [id]);
const onUpdate = async () => {
await fetchProject(id);
};
// TODO: Remove organization projects
const { projects: organizationProjects } =
useOutletContext<ProjectSearchOutletContext>();
const organizationProject = useMemo(() => {
return organizationProjects.find((project) => {
return project.id === id; return project.id === id;
}); });
}, [id, projects]); }, [id, organizationProjects]);
return ( return (
<div className="h-full"> <div className="h-full">
{project ? ( {project && organizationProject ? (
<> <>
<div className="flex p-4 gap-4 items-center"> <div className="flex p-4 gap-4 items-center">
<Button <Button
@ -32,7 +54,7 @@ const Project = () => {
{'<'} {'<'}
</Button> </Button>
<Typography variant="h3" className="grow"> <Typography variant="h3" className="grow">
{project?.title} {project?.name}
</Typography> </Typography>
<Button className="rounded-full" variant="outlined"> <Button className="rounded-full" variant="outlined">
Open Repo Open Repo
@ -43,7 +65,11 @@ const Project = () => {
</div> </div>
<HorizontalLine /> <HorizontalLine />
<div className="p-4"> <div className="p-4">
<ProjectTabs project={project} /> <ProjectTabs
project={project}
organizationProject={organizationProject}
onUpdate={onUpdate}
/>
</div> </div>
</> </>
) : ( ) : (

View File

@ -1,8 +1,8 @@
import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables } from './queries'; import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects, getEnvironmentVariables, getProject } from './queries';
import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, RemoveMemberResponse, UpdateDeploymentToProdResponse } from './types'; import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, RemoveMemberResponse, UpdateDeploymentToProdResponse, GetProjectResponse, UpdateProjectResponse, UpdateProjectInput } from './types';
import { removeMember, addEnvironmentVariables, updateDeploymentToProd } from './mutations'; import { removeMember, addEnvironmentVariables, updateDeploymentToProd, updateProjectMutation } from './mutations';
export interface GraphQLConfig { export interface GraphQLConfig {
gqlEndpoint: string; gqlEndpoint: string;
@ -39,6 +39,17 @@ export class GQLClient {
return data; return data;
} }
async getProject (projectId: string) : Promise<GetProjectResponse> {
const { data } = await this.client.query({
query: getProject,
variables: {
projectId
}
});
return data;
}
async getOrganizations () : Promise<GetOrganizationsResponse> { async getOrganizations () : Promise<GetOrganizationsResponse> {
const { data } = await this.client.query({ const { data } = await this.client.query({
query: getOrganizations query: getOrganizations
@ -124,4 +135,16 @@ export class GQLClient {
return data; return data;
} }
async updateProject (projectId: string, updateProject: UpdateProjectInput): Promise<UpdateProjectResponse> {
const { data } = await this.client.mutate({
mutation: updateProjectMutation,
variables: {
projectId,
updateProject
}
});
return data;
}
} }

View File

@ -17,3 +17,9 @@ mutation ($deploymentId: String!) {
updateDeploymentToProd(deploymentId: $deploymentId) updateDeploymentToProd(deploymentId: $deploymentId)
} }
`; `;
export const updateProjectMutation = gql`
mutation ($projectId: String!, $updateProject: UpdateProjectInput) {
updateProject(projectId: $projectId, updateProject: $updateProject)
}
`;

View File

@ -12,6 +12,28 @@ query {
} }
`; `;
export const getProject = gql`
query ($projectId: String!) {
project(projectId: $projectId) {
createdAt
description
id
name
template
updatedAt
prodBranch
framework
repository
webhooks
owner {
id
name
email
}
}
}
`;
export const getOrganizations = gql` export const getOrganizations = gql`
query { query {
organizations { organizations {

View File

@ -152,6 +152,10 @@ export type GetUserResponse = {
user: User user: User
} }
export type GetProjectResponse = {
project: Project | null
}
export type SearchProjectsResponse = { export type SearchProjectsResponse = {
searchProjects: Project[] searchProjects: Project[]
} }
@ -169,3 +173,12 @@ export type AddEnvironmentVariableInput = {
export type UpdateDeploymentToProdResponse = { export type UpdateDeploymentToProdResponse = {
updateDeploymentToProd: boolean; updateDeploymentToProd: boolean;
} }
export type UpdateProjectResponse = {
updateProject: boolean;
}
export type UpdateProjectInput = {
name: string
description: string
}