Implement routes for project settings tab (#64)

* Add routes to settings tab panel

* Refactor code to move settings tab components to pages

* Rename registry fields in project and deployment entity

* Use kebab case for routes

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
2024-02-12 15:18:00 +05:30
committed by GitHub
co-authored by neeraj
parent 559e0f8934
commit 76dfd3bb76
18 changed files with 215 additions and 198 deletions
@@ -3,7 +3,7 @@ import { UseFormRegister } from 'react-hook-form';
import { Typography, Input, IconButton } from '@material-tailwind/react';
import { EnvironmentVariablesFormValues } from './EnvironmentVariablesTabPanel';
import { EnvironmentVariablesFormValues } from '../../../../types/project';
interface AddEnvironmentVariableRowProps {
onDelete: () => void;
@@ -1,56 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Domain, Project } from 'gql-client';
import { Button, Typography } from '@material-tailwind/react';
import DomainCard from './DomainCard';
import { useGQLClient } from '../../../../context/GQLClientContext';
import repositories from '../../../../assets/repositories.json';
const Domains = ({ project }: { project: Project }) => {
const client = useGQLClient();
const [domains, setDomains] = useState<Domain[]>([]);
const fetchDomains = async () => {
if (project === undefined) {
return;
}
const fetchedDomains = await client.getDomains(project.id);
setDomains(fetchedDomains.domains);
};
useEffect(() => {
fetchDomains();
}, []);
return (
<>
<div className="flex justify-between p-2">
<Typography variant="h3">Domain</Typography>
<Link to="domain/add">
<Button color="blue" variant="outlined" className="rounded-full">
<i>^</i> Add domain
</Button>
</Link>
</div>
{domains.map((domain) => {
return (
<DomainCard
domains={domains}
domain={domain}
key={domain.id}
// TODO: Use github API for getting linked repository
repo={repositories[0]!}
project={project}
onUpdate={fetchDomains}
/>
);
})}
</>
);
};
export default Domains;
@@ -1,253 +0,0 @@
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,
Collapse,
Card,
Button,
Checkbox,
Chip,
} from '@material-tailwind/react';
import AddEnvironmentVariableRow from './AddEnvironmentVariableRow';
import DisplayEnvironmentVariables from './DisplayEnvironmentVariables';
import HorizontalLine from '../../../HorizontalLine';
import { useGQLClient } from '../../../../context/GQLClientContext';
export type EnvironmentVariablesFormValues = {
variables: {
key: string;
value: string;
}[];
environment: {
development: boolean;
preview: boolean;
production: boolean;
};
};
export const EnvironmentVariablesTabPanel = () => {
const { id } = useParams();
const client = useGQLClient();
const [environmentVariables, setEnvironmentVariables] = useState<
EnvironmentVariable[]
>([]);
const {
handleSubmit,
register,
control,
reset,
formState: { isSubmitSuccessful, errors },
} = useForm<EnvironmentVariablesFormValues>({
defaultValues: {
variables: [{ key: '', value: '' }],
environment: {
development: false,
preview: false,
production: false,
},
},
});
const [createNewVariable, setCreateNewVariable] = useState(false);
const { fields, append, remove } = useFieldArray({
name: 'variables',
control,
rules: {
required: 'Add at least 1 environment variables',
},
});
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset, id]);
const getEnvironmentVariables = useCallback(
(environment: Environment) => {
return environmentVariables.filter(
(item) => item.environment === environment,
);
},
[environmentVariables, id],
);
const isFieldEmpty = useMemo(() => {
if (errors.variables) {
return fields.some((_, index) => {
if (
errors.variables![index]?.value?.type === 'required' ||
errors.variables![index]?.key?.type === 'required'
) {
return true;
}
});
}
return false;
}, [fields, errors.variables, id]);
const fetchEnvironmentVariables = useCallback(
async (id: string | undefined) => {
if (id) {
const { environmentVariables } =
await client.getEnvironmentVariables(id);
setEnvironmentVariables(environmentVariables);
}
},
[],
);
useEffect(() => {
fetchEnvironmentVariables(id);
}, [id]);
const createEnvironmentVariablesHandler = useCallback(
async (createFormData: EnvironmentVariablesFormValues) => {
const environmentVariables = createFormData.variables.map((variable) => {
return {
key: variable.key,
value: variable.value,
environments: Object.entries(createFormData.environment)
.filter(([, value]) => value === true)
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
};
});
const { addEnvironmentVariables: isEnvironmentVariablesAdded } =
await client.addEnvironmentVariables(id!, environmentVariables);
if (isEnvironmentVariablesAdded) {
reset();
setCreateNewVariable((cur) => !cur);
fetchEnvironmentVariables(id);
toast.success(
createFormData.variables.length > 1
? `${createFormData.variables.length} variables added`
: `Variable added`,
);
} else {
toast.error('Environment variables not added');
}
},
[id, client],
);
return (
<>
<Typography variant="h6">Environment variables</Typography>
<Typography variant="small" className="font-medium text-gray-800">
A new deployment is required for your changes to take effect.
</Typography>
<div className="bg-gray-300 rounded-lg p-2">
<div
className="text-black"
onClick={() => setCreateNewVariable((cur) => !cur)}
>
+ Create new variable
</div>
<Collapse open={createNewVariable}>
<Card className="bg-white p-2">
<form onSubmit={handleSubmit(createEnvironmentVariablesHandler)}>
{fields.map((field, index) => {
return (
<AddEnvironmentVariableRow
key={field.id}
index={index}
register={register}
onDelete={() => remove(index)}
isDeleteDisabled={fields.length === 1}
/>
);
})}
<div className="flex gap-1 p-2">
<Button
variant="outlined"
size="sm"
onClick={() =>
append({
key: '',
value: '',
})
}
>
+ Add variable
</Button>
<Button variant="outlined" size="sm">
^ Import .env
</Button>
</div>
{isFieldEmpty && (
<Chip
value="^ Please ensure no fields are empty before saving."
variant="outlined"
color="red"
size="sm"
/>
)}
<div>
<Checkbox
crossOrigin={undefined}
label="Production"
{...register(`environment.production`)}
color="blue"
/>
<Checkbox
crossOrigin={undefined}
label="Preview"
{...register(`environment.preview`)}
color="blue"
/>
<Checkbox
crossOrigin={undefined}
label="Development"
{...register(`environment.development`)}
color="blue"
/>
</div>
<div className="p-2">
<Button size="lg" color="blue" type="submit">
Save changes
</Button>
</div>
</form>
</Card>
</Collapse>
</div>
<div className="p-2">
<DisplayEnvironmentVariables
environment={Environment.Production}
variables={getEnvironmentVariables(Environment.Production)}
onUpdate={async () => {
await fetchEnvironmentVariables(id);
}}
/>
<HorizontalLine />
<DisplayEnvironmentVariables
environment={Environment.Preview}
variables={getEnvironmentVariables(Environment.Preview)}
onUpdate={async () => {
await fetchEnvironmentVariables(id);
}}
/>
<HorizontalLine />
<DisplayEnvironmentVariables
environment={Environment.Development}
variables={getEnvironmentVariables(Environment.Development)}
onUpdate={async () => {
await fetchEnvironmentVariables(id);
}}
/>
</div>
</>
);
};
@@ -1,252 +0,0 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import toast from 'react-hot-toast';
import { Organization, Project } from 'gql-client';
import { Button, Typography, Input, Option } from '@material-tailwind/react';
import DeleteProjectDialog from './DeleteProjectDialog';
import ConfirmDialog from '../../../shared/ConfirmDialog';
import { useGQLClient } from '../../../../context/GQLClientContext';
import AsyncSelect from '../../../shared/AsyncSelect';
const CopyIcon = ({ value }: { value: string }) => {
return (
<span
onClick={() => {
navigator.clipboard.writeText(value);
toast.success('Project ID copied');
}}
className="cursor-pointer"
>
^
</span>
);
};
const GeneralTabPanel = ({
project,
onUpdate,
}: {
project: Project;
onUpdate: () => Promise<void>;
}) => {
const client = useGQLClient();
const [transferOrganizations, setTransferOrganizations] = useState<
Organization[]
>([]);
const [selectedTransferOrganization, setSelectedTransferOrganization] =
useState('');
const {
handleSubmit: handleTransfer,
control,
formState,
reset: transferFormReset,
} = useForm({
defaultValues: {
orgId: '',
},
});
const [openTransferDialog, setOpenTransferDialog] = useState(false);
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const handleDeleteProjectDialog = () =>
setOpenDeleteDialog(!openDeleteDialog);
const {
handleSubmit,
register,
reset,
formState: updateProjectFormState,
} = useForm({
defaultValues: {
appName: project.name,
description: project.description,
},
});
const fetchUserOrganizations = useCallback(async () => {
const { organizations } = await client.getOrganizations();
const orgsToTransfer = organizations.filter(
(org) => org.id !== project.organization.id,
);
setTransferOrganizations(orgsToTransfer);
}, [project]);
const handleTransferProject = useCallback(async () => {
const { updateProject: isTransferred } = await client.updateProject(
project.id,
{
organizationId: selectedTransferOrganization,
},
);
setOpenTransferDialog(!openTransferDialog);
if (isTransferred) {
toast.success('Project transferred');
await fetchUserOrganizations();
await onUpdate();
transferFormReset();
} else {
toast.error('Project not transrfered');
}
}, [project, selectedTransferOrganization]);
const selectedUserOrgName = useMemo(() => {
return (
transferOrganizations.find(
(org) => org.id === selectedTransferOrganization,
)?.name || ''
);
}, [transferOrganizations, selectedTransferOrganization]);
useEffect(() => {
fetchUserOrganizations();
}, [project]);
useEffect(() => {
reset({ appName: project.name, description: project.description });
}, [project]);
return (
<>
<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(({ orgId }) => {
setSelectedTransferOrganization(orgId);
setOpenTransferDialog(!openTransferDialog);
})}
>
<Typography variant="small" className="font-medium text-gray-800">
Choose team
</Typography>
<Controller
name="orgId"
rules={{ required: 'This field is required' }}
control={control}
render={({ field }) => (
<AsyncSelect
{...field}
// TODO: Implement placeholder for select
label={!field.value ? 'Select an account / team' : ''}
>
{transferOrganizations.map((org, key) => (
<Option key={key} value={org.id}>
^ {org.name}
</Option>
))}
</AsyncSelect>
)}
/>
<Button
variant="gradient"
size="sm"
className="mt-1"
disabled={!formState.isValid}
type="submit"
>
Transfer
</Button>
</form>
<ConfirmDialog
dialogTitle="Transfer project"
handleOpen={() => setOpenTransferDialog(!openTransferDialog)}
open={openTransferDialog}
confirmButtonTitle="Yes, Confirm transfer"
handleConfirm={handleTransferProject}
color="blue"
>
<Typography variant="small">
Upon confirmation, your project {project.name} will be transferred
from {project.organization.name} to {selectedUserOrgName}.
</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={project}
/>
</div>
</>
);
};
export default GeneralTabPanel;
@@ -1,192 +0,0 @@
import React, { useCallback, useEffect } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { Project } from 'gql-client';
import { Button, Input, Switch, Typography } from '@material-tailwind/react';
import WebhookCard from './WebhookCard';
import { useGQLClient } from '../../../../context/GQLClientContext';
type UpdateProdBranchValues = {
prodBranch: string;
};
type UpdateWebhooksValues = {
webhookUrl: string;
};
const GitTabPanel = ({
project,
onUpdate,
}: {
project: Project;
onUpdate: () => Promise<void>;
}) => {
const client = useGQLClient();
const {
register: registerProdBranch,
handleSubmit: handleSubmitProdBranch,
reset: resetProdBranch,
} = useForm({
defaultValues: {
prodBranch: project.prodBranch,
},
});
const updateProdBranchHandler: SubmitHandler<UpdateProdBranchValues> =
useCallback(
async (data) => {
const { updateProject } = await client.updateProject(project.id, {
prodBranch: data.prodBranch,
});
if (updateProject) {
await onUpdate();
toast.success('Production branch upadated successfully');
} else {
toast.error('Error updating production branch');
}
},
[project],
);
const {
register: registerWebhooks,
handleSubmit: handleSubmitWebhooks,
reset: resetWebhooks,
} = useForm({
defaultValues: {
webhookUrl: '',
},
});
const updateWebhooksHandler: SubmitHandler<UpdateWebhooksValues> =
useCallback(
async (data) => {
const { updateProject } = await client.updateProject(project.id, {
webhooks: [...project.webhooks, data.webhookUrl],
});
if (updateProject) {
await onUpdate();
toast.success('Webhook added successfully');
} else {
toast.error('Error adding webhook');
}
resetWebhooks();
},
[project],
);
useEffect(() => {
resetProdBranch({
prodBranch: project.prodBranch,
});
}, [project]);
const handleDeleteWebhook = async (index: number) => {
project.webhooks.splice(index, 1);
const { updateProject } = await client.updateProject(project.id, {
webhooks: project.webhooks,
});
if (updateProject) {
await onUpdate();
toast.success('Webhook deleted successfully');
} else {
toast.error('Error deleting webhook');
}
};
return (
<>
<div className="mb-2 p-2">
<Typography variant="h6" className="text-black">
Git repository
</Typography>
<div className="flex justify-between mt-4">
<div>
<Typography variant="small">Pull request comments</Typography>
</div>
<div>
<Switch crossOrigin={undefined} defaultChecked />
</div>
</div>
<div className="flex justify-between">
<div>
<Typography variant="small">Commit comments</Typography>
</div>
<div>
<Switch crossOrigin={undefined} />
</div>
</div>
</div>
<form onSubmit={handleSubmitProdBranch(updateProdBranchHandler)}>
<div className="mb-2 p-2">
<Typography variant="h6" className="text-black">
Production branch
</Typography>
<Typography variant="small">
By default, each commit pushed to the{' '}
<span className="font-bold">{project.prodBranch}</span> branch
initiates a production deployment. You can opt for a different
branch for deployment in the settings.
</Typography>
<Typography variant="small">Branch name</Typography>
<Input
crossOrigin={undefined}
{...registerProdBranch('prodBranch')}
/>
<Button size="sm" className="mt-1" type="submit">
Save
</Button>
</div>
</form>
<form onSubmit={handleSubmitWebhooks(updateWebhooksHandler)}>
<div className="mb-2 p-2">
<Typography variant="h6" className="text-black">
Deploy webhooks
</Typography>
<Typography variant="small">
Webhooks configured to trigger when there is a change in a
project&apos;s build or deployment status.
</Typography>
<div className="flex gap-1">
<div className="grow">
<Typography variant="small">Webhook URL</Typography>
<Input
crossOrigin={undefined}
{...registerWebhooks('webhookUrl')}
/>
</div>
<div className="self-end">
<Button size="sm" type="submit">
Save
</Button>
</div>
</div>
</div>
</form>
<div className="mb-2 p-2">
{project.webhooks.map((webhookUrl, index) => {
return (
<WebhookCard
webhookUrl={webhookUrl}
onDelete={() => handleDeleteWebhook(index)}
key={index}
/>
);
})}
</div>
</>
);
};
export default GitTabPanel;
@@ -1,135 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import toast, { Toaster } from 'react-hot-toast';
import {
Permission,
Project,
AddProjectMemberInput,
ProjectMember,
} from 'gql-client';
import { Chip, Button, Typography } from '@material-tailwind/react';
import MemberCard from './MemberCard';
import AddMemberDialog from './AddMemberDialog';
import { useGQLClient } from '../../../../context/GQLClientContext';
const FIRST_MEMBER_CARD = 0;
const MembersTabPanel = ({ project }: { project: Project }) => {
const client = useGQLClient();
const [addmemberDialogOpen, setAddMemberDialogOpen] = useState(false);
const [projectMembers, setProjectMembers] = useState<ProjectMember[]>([]);
const fetchProjectMembers = useCallback(async () => {
const { projectMembers } = await client.getProjectMembers(project.id);
setProjectMembers(projectMembers);
}, [project.id]);
const addMemberHandler = useCallback(
async (data: AddProjectMemberInput) => {
const { addProjectMember: isProjectMemberAdded } =
await client.addProjectMember(project.id, data);
if (isProjectMemberAdded) {
await fetchProjectMembers();
toast.success('Invitation sent');
} else {
toast.error('Invitation not sent');
}
},
[project],
);
const removeMemberHandler = async (projectMemberId: string) => {
const { removeProjectMember: isMemberRemoved } =
await client.removeProjectMember(projectMemberId);
if (isMemberRemoved) {
await fetchProjectMembers();
toast.success('Member removed from project');
} else {
toast.error('Not able to remove member');
}
};
const updateProjectMemberHandler = useCallback(
async (projectMemberId: string, data: { permissions: Permission[] }) => {
const { updateProjectMember: isProjectMemberUpdated } =
await client.updateProjectMember(projectMemberId, data);
if (isProjectMemberUpdated) {
await fetchProjectMembers();
toast.success('Project member permission updated');
} else {
toast.error('Project member permission not updated');
}
},
[],
);
useEffect(() => {
fetchProjectMembers();
}, [project.id, fetchProjectMembers]);
return (
<div className="p-2 mb-20">
<div className="flex justify-between mb-2">
<div className="flex">
<Typography variant="h6">Members</Typography>
<div>
<Chip
className="normal-case ml-3 font-normal"
size="sm"
value={projectMembers.length + 1}
/>
</div>
</div>
<div>
<Button
size="sm"
onClick={() => setAddMemberDialogOpen((preVal) => !preVal)}
>
+ Add member
</Button>
</div>
</div>
<MemberCard
member={project.owner}
isFirstCard={true}
isOwner={true}
isPending={false}
permissions={[]}
/>
{projectMembers.map((projectMember, index) => {
return (
<MemberCard
member={projectMember.member}
key={projectMember.id}
isFirstCard={index === FIRST_MEMBER_CARD}
isOwner={projectMember.member.id === project.owner.id}
isPending={projectMember.isPending}
permissions={projectMember.permissions}
onRemoveProjectMember={async () =>
await removeMemberHandler(projectMember.id)
}
onUpdateProjectMember={async (data) => {
await updateProjectMemberHandler(projectMember.id, data);
}}
/>
);
})}
<AddMemberDialog
handleOpen={() => {
setAddMemberDialogOpen((preVal) => !preVal);
}}
open={addmemberDialogOpen}
handleAddMember={addMemberHandler}
/>
<Toaster />
</div>
);
};
export default MembersTabPanel;