diff --git a/.vscode/settings.json b/.vscode/settings.json index f3e08235..c831fde1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { // IntelliSense for taiwind variants "tailwindCSS.experimental.classRegex": [ - ["tv\\((([^()]*|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] + "tv\\('([^)]*)\\')", + "(?:'|\"|`)([^\"'`]*)(?:'|\"|`)" ] } diff --git a/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx b/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx index e191fe8c..c4940bac 100644 --- a/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx +++ b/packages/frontend/src/components/projects/project/deployments/DeploymentDetailsCard.tsx @@ -1,31 +1,28 @@ -import React, { useState } from 'react'; -import toast from 'react-hot-toast'; +import React from 'react'; import { + Deployment, + DeploymentStatus, + Domain, Environment, Project, - Domain, - DeploymentStatus, - Deployment, } from 'gql-client'; - +import { Avatar } from 'components/shared/Avatar'; import { - Menu, - MenuHandler, - MenuList, - MenuItem, - Typography, - Chip, - ChipProps, - Tooltip, -} from '@material-tailwind/react'; - -import { relativeTimeMs } from '../../../../utils/time'; -import ConfirmDialog from '../../../shared/ConfirmDialog'; -import DeploymentDialogBodyCard from './DeploymentDialogBodyCard'; -import AssignDomainDialog from './AssignDomainDialog'; -import { useGQLClient } from '../../../../context/GQLClientContext'; + BranchStrokeIcon, + CheckRoundFilledIcon, + ClockOutlineIcon, + CommitIcon, + LoadingIcon, + WarningIcon, +} from 'components/shared/CustomIcon'; +import { Heading } from 'components/shared/Heading'; +import { OverflownText } from 'components/shared/OverflownText'; +import { Tag, TagTheme } from 'components/shared/Tag'; +import { getInitials } from 'utils/geInitials'; +import { relativeTimeMs } from 'utils/time'; import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants'; import { formatAddress } from '../../../../utils/format'; +import { DeploymentMenu } from './DeploymentMenu'; interface DeployDetailsCardProps { deployment: Deployment; @@ -35,10 +32,12 @@ interface DeployDetailsCardProps { prodBranchDomains: Domain[]; } -const STATUS_COLORS: { [key in DeploymentStatus]: ChipProps['color'] } = { - [DeploymentStatus.Building]: 'blue', - [DeploymentStatus.Ready]: 'green', - [DeploymentStatus.Error]: 'red', +const STATUS_COLORS: { + [key in DeploymentStatus]: TagTheme['type']; +} = { + [DeploymentStatus.Building]: 'emphasized', + [DeploymentStatus.Ready]: 'positive', + [DeploymentStatus.Error]: 'negative', }; const DeploymentDetailsCard = ({ @@ -48,241 +47,99 @@ const DeploymentDetailsCard = ({ project, prodBranchDomains, }: DeployDetailsCardProps) => { - const client = useGQLClient(); - - const [changeToProduction, setChangeToProduction] = useState(false); - const [redeployToProduction, setRedeployToProduction] = useState(false); - const [rollbackDeployment, setRollbackDeployment] = useState(false); - const [assignDomainDialog, setAssignDomainDialog] = 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'); + const getIconByDeploymentStatus = (status: DeploymentStatus) => { + if (status === DeploymentStatus.Building) { + return ; } - }; - - const redeployToProd = async () => { - const isRedeployed = await client.redeployToProd(deployment.id); - if (isRedeployed) { - await onUpdate(); - toast.success('Redeployed to production'); - } else { - toast.error('Unable to redeploy to production'); + if (status === DeploymentStatus.Ready) { + return ; } - }; - const rollbackDeploymentHandler = async () => { - const isRollbacked = await client.rollbackDeployment( - project.id, - deployment.id, - ); - if (isRollbacked) { - await onUpdate(); - toast.success('Deployment rolled back'); - } else { - toast.error('Unable to rollback deployment'); + if (status === DeploymentStatus.Error) { + return ; } }; return ( -
-
-
- {deployment.url && ( - +
+
+ {/* DEPLOYMENT URL */} + {deployment.url && ( + + {deployment.url} - - )} -
- + + + )} + {deployment.environment === Environment.Production ? `Production ${deployment.isCurrent ? '(Current)' : ''}` : 'Preview'} - +
-
- ^} + + {/* DEPLOYMENT STATUS */} +
+ + {deployment.status} + +
+ + {/* DEPLOYMENT COMMIT DETAILS */} +
+ + + {deployment.branch} + + + + + {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '} + {deployment.commitMessage} + + +
+ + {/* DEPLOYMENT INFOs */} +
+
+ + + {relativeTimeMs(deployment.createdAt)} + +
+ +
+ + + {formatAddress(deployment.createdBy.name ?? '')} + +
+
-
- - ^ {deployment.branch} - - - ^ {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '} - {deployment.commitMessage} - -
-
- - ^ {relativeTimeMs(deployment.createdAt)} ^{' '} - - {formatAddress(deployment.createdBy.name ?? '')} - - - - - - - - - - ^ Visit - - - setAssignDomainDialog(!assignDomainDialog)} - placeholder={''} - > - ^ Assign domain - - setChangeToProduction(!changeToProduction)} - disabled={!(deployment.environment !== Environment.Production)} - placeholder={''} - > - ^ Change to production - -
- setRedeployToProduction(!redeployToProduction)} - disabled={ - !( - deployment.environment === Environment.Production && - deployment.isCurrent - ) - } - placeholder={''} - > - ^ Redeploy to production - - setRollbackDeployment(!rollbackDeployment)} - disabled={ - deployment.isCurrent || - deployment.environment !== Environment.Production || - !Boolean(currentDeployment) - } - placeholder={''} - > - ^ Rollback to this version - -
-
-
- setChangeToProduction((preVal) => !preVal)} - open={changeToProduction} - confirmButtonTitle="Change" - color="blue" - handleConfirm={async () => { - await updateDeployment(); - setChangeToProduction((preVal) => !preVal); - }} - > -
- - Upon confirmation, this deployment will be changed to production. - - - - The new deployment will be associated with these domains: - - {prodBranchDomains.length > 0 && - prodBranchDomains.map((value) => { - return ( - - ^ {value.name} - - ); - })} -
-
- setRedeployToProduction((preVal) => !preVal)} - open={redeployToProduction} - confirmButtonTitle="Redeploy" - color="blue" - handleConfirm={async () => { - await redeployToProd(); - setRedeployToProduction((preVal) => !preVal); - }} - > -
- - Upon confirmation, new deployment will be created with the same - source code as current deployment. - - - - These domains will point to your new deployment: - - {deployment.domain?.name && ( - - {deployment.domain?.name} - - )} -
-
- {Boolean(currentDeployment) && ( - setRollbackDeployment((preVal) => !preVal)} - open={rollbackDeployment} - confirmButtonTitle="Rollback" - color="blue" - handleConfirm={async () => { - await rollbackDeploymentHandler(); - setRollbackDeployment((preVal) => !preVal); - }} - > -
- - Upon confirmation, this deployment will replace your current - deployment - - - - - These domains will point to your new deployment: - - - ^ {currentDeployment.domain?.name} - -
-
- )} - setAssignDomainDialog(!assignDomainDialog)} - />
); }; diff --git a/packages/frontend/src/components/projects/project/deployments/DeploymentMenu.tsx b/packages/frontend/src/components/projects/project/deployments/DeploymentMenu.tsx new file mode 100644 index 00000000..f4eb9808 --- /dev/null +++ b/packages/frontend/src/components/projects/project/deployments/DeploymentMenu.tsx @@ -0,0 +1,268 @@ +import React, { useState } from 'react'; +import toast from 'react-hot-toast'; +import { Deployment, Domain, Environment, Project } from 'gql-client'; +import { Button } from 'components/shared/Button'; +import { + GlobeIcon, + HorizontalDotIcon, + LinkIcon, + RefreshIcon, + RocketIcon, + UndoIcon, +} from 'components/shared/CustomIcon'; +import { + Menu, + MenuHandler, + MenuItem, + MenuList, +} from '@material-tailwind/react'; +import { ComponentPropsWithRef } from 'react'; +import ConfirmDialog from '../../../shared/ConfirmDialog'; +import AssignDomainDialog from './AssignDomainDialog'; +import DeploymentDialogBodyCard from './DeploymentDialogBodyCard'; +import { Typography } from '@material-tailwind/react'; +import { useGQLClient } from '../../../../context/GQLClientContext'; +import { cn } from 'utils/classnames'; + +interface DeploymentMenuProps extends ComponentPropsWithRef<'div'> { + deployment: Deployment; + currentDeployment: Deployment; + onUpdate: () => Promise; + project: Project; + prodBranchDomains: Domain[]; +} + +export const DeploymentMenu = ({ + deployment, + currentDeployment, + onUpdate, + project, + prodBranchDomains, + className, + ...props +}: DeploymentMenuProps) => { + const client = useGQLClient(); + + const [changeToProduction, setChangeToProduction] = useState(false); + const [redeployToProduction, setRedeployToProduction] = useState(false); + const [rollbackDeployment, setRollbackDeployment] = useState(false); + const [assignDomainDialog, setAssignDomainDialog] = 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'); + } + }; + + const redeployToProd = async () => { + const isRedeployed = await client.redeployToProd(deployment.id); + if (isRedeployed) { + await onUpdate(); + toast.success('Redeployed to production'); + } else { + toast.error('Unable to redeploy to production'); + } + }; + + const rollbackDeploymentHandler = async () => { + const isRollbacked = await client.rollbackDeployment( + project.id, + deployment.id, + ); + if (isRollbacked) { + await onUpdate(); + toast.success('Deployment rolled back'); + } else { + toast.error('Unable to rollback deployment'); + } + }; + + return ( + <> +
+ + + +
+ {/* Dialogs */} + setChangeToProduction((preVal) => !preVal)} + open={changeToProduction} + confirmButtonTitle="Change" + color="blue" + handleConfirm={async () => { + await updateDeployment(); + setChangeToProduction((preVal) => !preVal); + }} + > +
+ + Upon confirmation, this deployment will be changed to production. + + + + The new deployment will be associated with these domains: + + {prodBranchDomains.length > 0 && + prodBranchDomains.map((value) => { + return ( + + ^ {value.name} + + ); + })} +
+
+ setRedeployToProduction((preVal) => !preVal)} + open={redeployToProduction} + confirmButtonTitle="Redeploy" + color="blue" + handleConfirm={async () => { + await redeployToProd(); + setRedeployToProduction((preVal) => !preVal); + }} + > +
+ + Upon confirmation, new deployment will be created with the same + source code as current deployment. + + + + These domains will point to your new deployment: + + {deployment.domain?.name && ( + + {deployment.domain?.name} + + )} +
+
+ {Boolean(currentDeployment) && ( + setRollbackDeployment((preVal) => !preVal)} + open={rollbackDeployment} + confirmButtonTitle="Rollback" + color="blue" + handleConfirm={async () => { + await rollbackDeploymentHandler(); + setRollbackDeployment((preVal) => !preVal); + }} + > +
+ + Upon confirmation, this deployment will replace your current + deployment + + + + + These domains will point to your new deployment: + + + ^ {currentDeployment.domain?.name} + +
+
+ )} + setAssignDomainDialog(!assignDomainDialog)} + /> + + ); +}; diff --git a/packages/frontend/src/components/projects/project/deployments/FilterForm.tsx b/packages/frontend/src/components/projects/project/deployments/FilterForm.tsx index c28c6218..dd549c57 100644 --- a/packages/frontend/src/components/projects/project/deployments/FilterForm.tsx +++ b/packages/frontend/src/components/projects/project/deployments/FilterForm.tsx @@ -44,7 +44,7 @@ const FilterForm = ({ value, onChange }: FilterFormProps) => { }, [value]); return ( -
+
{ + return ( + + + + + + + + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/RefreshIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/RefreshIcon.tsx new file mode 100644 index 00000000..3873c371 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/RefreshIcon.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const RefreshIcon = (props: CustomIconProps) => { + return ( + + + + + + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/RocketIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/RocketIcon.tsx new file mode 100644 index 00000000..488fba8f --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/RocketIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const RocketIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/UndoIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/UndoIcon.tsx new file mode 100644 index 00000000..713a0b6e --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/UndoIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const UndoIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/index.ts b/packages/frontend/src/components/shared/CustomIcon/index.ts index b9965188..61239304 100644 --- a/packages/frontend/src/components/shared/CustomIcon/index.ts +++ b/packages/frontend/src/components/shared/CustomIcon/index.ts @@ -41,6 +41,10 @@ export * from './BranchStrokeIcon'; export * from './StorageIcon'; export * from './LinkIcon'; export * from './CursorBoxIcon'; +export * from './CommitIcon'; +export * from './RocketIcon'; +export * from './RefreshIcon'; +export * from './UndoIcon'; // Templates export * from './templates'; diff --git a/packages/frontend/src/components/shared/OverflownText/OverflownText.tsx b/packages/frontend/src/components/shared/OverflownText/OverflownText.tsx new file mode 100644 index 00000000..b03ea580 --- /dev/null +++ b/packages/frontend/src/components/shared/OverflownText/OverflownText.tsx @@ -0,0 +1,71 @@ +import { cn } from 'utils/classnames'; +import { Tooltip, TooltipProps } from 'components/shared/Tooltip'; +import { debounce } from 'lodash'; +import React, { + ComponentPropsWithRef, + PropsWithChildren, + useRef, + useState, + useEffect, +} from 'react'; +import { PolymorphicProps } from 'types/common'; + +interface OverflownTextProps extends ComponentPropsWithRef<'span'> { + tooltipProps?: TooltipProps; + content?: string; +} + +type ElementType = 'span' | 'div'; + +// This component is used to truncate text and show a tooltip if the text is overflown. +export const OverflownText = ({ + tooltipProps, + children, + content, + className, + as, + ...props +}: PropsWithChildren>) => { + const ref = useRef(null); + const [isOverflown, setIsOverflown] = useState(false); + + useEffect(() => { + const element = ref.current as HTMLElement | null; + if (!element) return; + + setIsOverflown(element.scrollWidth > element.clientWidth); + + const handleResize = () => { + const isOverflown = element.scrollWidth > element.clientWidth; + setIsOverflown(isOverflown); + }; + + window.addEventListener('resize', debounce(handleResize, 500)); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + const Component = as || 'span'; + + return ( + + + {children} + + + ); +}; diff --git a/packages/frontend/src/components/shared/OverflownText/index.ts b/packages/frontend/src/components/shared/OverflownText/index.ts new file mode 100644 index 00000000..1ffb16f2 --- /dev/null +++ b/packages/frontend/src/components/shared/OverflownText/index.ts @@ -0,0 +1 @@ +export * from './OverflownText'; diff --git a/packages/frontend/src/components/shared/Tag/Tag.theme.ts b/packages/frontend/src/components/shared/Tag/Tag.theme.ts index a027185d..884f79bc 100644 --- a/packages/frontend/src/components/shared/Tag/Tag.theme.ts +++ b/packages/frontend/src/components/shared/Tag/Tag.theme.ts @@ -4,7 +4,7 @@ import type { VariantProps } from 'tailwind-variants'; export const tagTheme = tv( { slots: { - wrapper: ['flex', 'gap-1.5', 'rounded-lg', 'border'], + wrapper: ['inline-flex', 'gap-1.5', 'rounded-lg', 'border'], icon: [], label: ['font-inter', 'text-xs'], }, diff --git a/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx b/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx index d26a2dd4..d5f0346f 100644 --- a/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx +++ b/packages/frontend/src/pages/org-slug/projects/id/Deployments.tsx @@ -88,17 +88,17 @@ const DeploymentsTabPanel = () => { setFilterValue(DEFAULT_FILTER_VALUE); }, []); - const onUpdateDeploymenToProd = async () => { + const onUpdateDeploymentToProd = async () => { await fetchDeployments(); }; return ( -
+
setFilterValue(value)} /> -
+
{Boolean(filteredDeployments.length) ? ( filteredDeployments.map((deployment, key) => { return ( @@ -106,7 +106,7 @@ const DeploymentsTabPanel = () => { deployment={deployment} key={key} currentDeployment={currentDeployment!} - onUpdate={onUpdateDeploymenToProd} + onUpdate={onUpdateDeploymentToProd} project={project} prodBranchDomains={prodBranchDomains} /> diff --git a/packages/frontend/tailwind.config.js b/packages/frontend/tailwind.config.js index c362e898..4efc11fe 100644 --- a/packages/frontend/tailwind.config.js +++ b/packages/frontend/tailwind.config.js @@ -12,6 +12,9 @@ export default withMT({ zIndex: { tooltip: '52', }, + letterSpacing: { + tight: '-0.084px', + }, fontFamily: { sans: ['Inter', 'sans-serif'], display: ['Inter Display', 'sans-serif'],