Add GQL mutation to update deployment to production (#39)

* Add mutation to update deployment to production

* Implement gql client mutation and frontend to update deployment to production

* Add toast message when deployment is changed to production

* Throw error from init db script if db aleardy exists

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-01-25 11:17:44 +05:30 committed by Ashwin Phatak
parent 44310d4eb8
commit 2fb048e8ab
12 changed files with 117 additions and 16 deletions

View File

@ -1,4 +1,4 @@
import { DataSource } from 'typeorm';
import { DataSource, DeepPartial } from 'typeorm';
import path from 'path';
import debug from 'debug';
import assert from 'assert';
@ -131,8 +131,7 @@ export class Database {
return environmentVariables;
}
async removeProjectMemberByMemberId (memberId: string): Promise<boolean> {
// TODO: Check if user is authorized to delete members
async removeProjectMemberById (memberId: string): Promise<boolean> {
const projectMemberRepository = this.dataSource.getRepository(ProjectMember);
const deleted = await projectMemberRepository.delete(memberId);
@ -206,4 +205,15 @@ export class Database {
return projects;
}
async updateDeploymentById (deploymentId: string, updates: DeepPartial<Deployment>): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment);
const updatedDeployment = await deploymentRepository.update({ id: Number(deploymentId) }, updates);
if (updatedDeployment.affected) {
return updatedDeployment.affected > 0;
} else {
return false;
}
}
}

View File

@ -12,7 +12,7 @@ import {
import { Project } from './Project';
import { Domain } from './Domain';
enum Environment {
export enum Environment {
Production = 'Production',
Preview = 'Preview',
Development = 'Development',

View File

@ -3,6 +3,7 @@ import assert from 'assert';
import { Database } from './database';
import { deploymentToGqlType, projectMemberToGqlType, projectToGqlType, environmentVariableToGqlType, isUserOwner } from './utils';
import { Environment } from './entity/Deployment';
const log = debug('snowball:database');
@ -102,7 +103,7 @@ export const createResolvers = async (db: Database): Promise<any> => {
assert(memberProject);
if (isUserOwner(String(context.userId), String(memberProject.owner.id))) {
return db.removeProjectMemberByMemberId(memberId);
return db.removeProjectMemberById(memberId);
} else {
throw new Error('Invalid operation: not authorized');
}
@ -119,6 +120,17 @@ export const createResolvers = async (db: Database): Promise<any> => {
log(err);
return false;
}
},
updateDeploymentToProd: async (_: any, { deploymentId }: {deploymentId: string }) => {
try {
return db.updateDeploymentById(deploymentId, {
environment: Environment.Production
});
} catch (err) {
log(err);
return false;
}
}
}
};

View File

@ -124,6 +124,7 @@ type Query {
type Mutation {
removeMember(memberId: String!): Boolean!
addEnvironmentVariables(projectId: String!, environmentVariables: [AddEnvironmentVariableInput!]): Boolean!
updateDeploymentToProd(deploymentId: String!): Boolean!
}
input AddEnvironmentVariableInput {

View File

@ -117,6 +117,8 @@ const main = async () => {
await dataSource.initialize();
await generateTestData(dataSource);
} else {
throw new Error('Database already exists');
}
};

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Typography } from '@material-tailwind/react';
@ -8,18 +8,48 @@ import FilterForm, {
StatusOptions,
} from './deployments/FilterForm';
import { DeploymentDetails } from '../../../types/project';
import { useGQLClient } from '../../../context/GQLClientContext';
const DEFAULT_FILTER_VALUE: FilterValue = {
searchedBranch: '',
status: StatusOptions.ALL_STATUS,
};
const DeploymentsTabPanel = ({
deployments,
}: {
deployments: DeploymentDetails[];
}) => {
const DeploymentsTabPanel = ({ projectId }: { projectId: string }) => {
const client = useGQLClient();
const [filterValue, setFilterValue] = useState(DEFAULT_FILTER_VALUE);
const [deployments, setDeployments] = useState<DeploymentDetails[]>([]);
const fetchDeployments = async () => {
const { deployments } = await client.getDeployments(projectId);
const updatedDeployments = deployments.map((deployment: any) => {
return {
...deployment,
isProduction: deployment.environment === 'Production',
author: '',
commit: {
hash: '',
message: '',
},
domain: deployment.domain
? {
...deployment.domain,
record: {
type: '',
name: '',
value: '',
},
}
: null,
};
});
setDeployments(updatedDeployments);
};
useEffect(() => {
fetchDeployments();
}, []);
const productionDeployment = useMemo(() => {
return deployments.find((deployment) => {
@ -49,12 +79,16 @@ const DeploymentsTabPanel = ({
return branchMatch && statusMatch && dateMatch;
}) as DeploymentDetails[];
}, [filterValue]);
}, [filterValue, deployments]);
const handleResetFilters = useCallback(() => {
setFilterValue(DEFAULT_FILTER_VALUE);
}, []);
const onUpdateDeploymenToProd = async () => {
await fetchDeployments();
};
return (
<div className="p-4">
<FilterForm
@ -69,6 +103,7 @@ const DeploymentsTabPanel = ({
deployment={deployment}
key={key}
productionDeployment={productionDeployment}
onUpdate={onUpdateDeploymenToProd}
/>
);
})

View File

@ -46,7 +46,7 @@ const ProjectTabs = ({ project }: ProjectTabsProps) => {
<OverviewTabPanel project={project} />
</TabPanel>
<TabPanel>
<DeploymentsTabPanel deployments={project.deployments} />
<DeploymentsTabPanel projectId={project.id} />
</TabPanel>
<TabPanel>
<Database />

View File

@ -9,15 +9,18 @@ import {
Chip,
ChipProps,
} from '@material-tailwind/react';
import toast from 'react-hot-toast';
import { relativeTimeMs } from '../../../../utils/time';
import ConfirmDialog from '../../../shared/ConfirmDialog';
import DeploymentDialogBodyCard from './DeploymentDialogBodyCard';
import { DeploymentDetails, Status } from '../../../../types/project';
import { useGQLClient } from '../../../../context/GQLClientContext';
interface DeployDetailsCardProps {
deployment: DeploymentDetails;
productionDeployment: DeploymentDetails;
onUpdate: () => Promise<void>;
}
const STATUS_COLORS: { [key in Status]: ChipProps['color'] } = {
@ -29,11 +32,24 @@ const STATUS_COLORS: { [key in Status]: ChipProps['color'] } = {
const DeploymentDetailsCard = ({
deployment,
productionDeployment,
onUpdate,
}: DeployDetailsCardProps) => {
const client = useGQLClient();
const [changeToProduction, setChangeToProduction] = useState(false);
const [redeployToProduction, setRedeployToProduction] = useState(false);
const [rollbackDeployment, setRollbackDeployment] = useState(false);
const updateDeployment = async () => {
const isUpdated = await client.updateDeploymentToProd(deployment.id);
if (isUpdated) {
await onUpdate();
toast.success('Deployment changed to production');
} else {
toast.error('Unable to change deployment to production');
}
};
return (
<div className="grid grid-cols-4 gap-2 border-b border-gray-300 p-3 my-2">
<div className="col-span-2">
@ -97,7 +113,10 @@ const DeploymentDetailsCard = ({
open={changeToProduction}
confirmButtonTitle="Change"
color="blue"
handleConfirm={() => setChangeToProduction((preVal) => !preVal)}
handleConfirm={() => {
updateDeployment();
setChangeToProduction((preVal) => !preVal);
}}
>
<div className="flex flex-col gap-2">
<Typography variant="small">

View File

@ -33,6 +33,7 @@ export interface ProjectMember {
}
export interface DeploymentDetails {
id: string;
title: string;
isProduction: boolean;
domain: DomainDetails;

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 } from './types';
import { removeMember, addEnvironmentVariables } from './mutations';
import { AddEnvironmentVariableInput, AddEnvironmentVariablesResponse, GetDeploymentsResponse, GetEnvironmentVariablesResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, RemoveMemberResponse, UpdateDeploymentToProdResponse } from './types';
import { removeMember, addEnvironmentVariables, updateDeploymentToProd } from './mutations';
export interface GraphQLConfig {
gqlEndpoint: string;
@ -113,4 +113,15 @@ export class GQLClient {
return data;
}
async updateDeploymentToProd (deploymentId: string): Promise<UpdateDeploymentToProdResponse> {
const { data } = await this.client.mutate({
mutation: updateDeploymentToProd,
variables: {
deploymentId
}
});
return data;
}
}

View File

@ -11,3 +11,9 @@ mutation ($projectId: String!, $environmentVariables: [AddEnvironmentVariableInp
addEnvironmentVariables(projectId: $projectId, environmentVariables: $environmentVariables)
}
`;
export const updateDeploymentToProd = gql`
mutation ($deploymentId: String!) {
updateDeploymentToProd(deploymentId: $deploymentId)
}
`;

View File

@ -165,3 +165,7 @@ export type AddEnvironmentVariableInput = {
key: string;
value: string;
}
export type UpdateDeploymentToProdResponse = {
updateDeploymentToProd: boolean;
}