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 { useCombobox } from 'downshift';
import { Project } from 'gql-client';
import {
List,
@ -9,8 +10,6 @@ import {
Typography,
} from '@material-tailwind/react';
import { Project } from 'gql-client';
import SearchBar from '../SearchBar';
import { useGQLClient } from '../../context/GQLClientContext';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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