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,18 +60,35 @@ 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, {
name: appName,
description,
});
if (updateProject) {
await onUpdate();
}
})}
>
<Typography variant="h6">Project info</Typography> <Typography variant="h6">Project info</Typography>
<Typography variant="small" className="font-medium text-gray-800"> <Typography variant="small" className="font-medium text-gray-800">
App name App name
@ -96,20 +115,26 @@ const GeneralTabPanel = () => {
<Input <Input
crossOrigin={undefined} crossOrigin={undefined}
variant="outlined" variant="outlined"
value={currentProject.id} value={project.id}
size="md" size="md"
disabled disabled
icon={<CopyIcon value={currentProject.id} />} icon={<CopyIcon value={project.id} />}
/> />
<Button type="submit" variant="gradient" size="sm" className="mt-1"> <Button
type="submit"
variant="gradient"
size="sm"
className="mt-1"
disabled={!updateProjectFormState.isDirty}
>
Save Save
</Button> </Button>
</form> </form>
<div className="mb-1"> <div className="mb-1">
<Typography variant="h6">Transfer project</Typography> <Typography variant="h6">Transfer project</Typography>
<Typography variant="small"> <Typography variant="small">
Transfer this app to your personal account or a team you are a Transfer this app to your personal account or a team you are a member
member of. of.
<Link to="" className="text-blue-500"> <Link to="" className="text-blue-500">
Learn more Learn more
</Link> </Link>
@ -167,8 +192,8 @@ const GeneralTabPanel = () => {
<div className="mb-1"> <div className="mb-1">
<Typography variant="h6">Delete project</Typography> <Typography variant="h6">Delete project</Typography>
<Typography variant="small"> <Typography variant="small">
The project will be permanently deleted, including its deployments The project will be permanently deleted, including its deployments and
and domains. This action is irreversible and can not be undone. domains. This action is irreversible and can not be undone.
</Typography> </Typography>
<Button <Button
variant="gradient" variant="gradient"
@ -185,8 +210,6 @@ const GeneralTabPanel = () => {
/> />
</div> </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
}