diff --git a/frontend/packages/core/src/index.tsx b/frontend/packages/core/src/index.tsx index b0dbb47f68..b739636284 100644 --- a/frontend/packages/core/src/index.tsx +++ b/frontend/packages/core/src/index.tsx @@ -61,6 +61,7 @@ export { Typography } from "./typography"; export { default as ClutchApp } from "./AppProvider"; export { useTheme } from "./AppProvider/themes"; export { ThemeProvider } from "./Theme"; +export { getDisplayName } from "./utils"; export { css as EMOTION_CSS, keyframes as EMOTION_KEYFRAMES } from "@emotion/react"; diff --git a/frontend/packages/core/src/link.tsx b/frontend/packages/core/src/link.tsx index 59890e5126..853ba551a4 100644 --- a/frontend/packages/core/src/link.tsx +++ b/frontend/packages/core/src/link.tsx @@ -8,6 +8,7 @@ type TextTransform = "none" | "capitalize" | "uppercase" | "lowercase" | "initia const StyledLink = styled(MuiLink)<{ $textTransform: LinkProps["textTransform"]; + $whiteSpace: LinkProps["whiteSpace"]; }>( ({ theme }: { theme: Theme }) => ({ display: "flex", @@ -20,11 +21,13 @@ const StyledLink = styled(MuiLink)<{ }), props => ({ textTransform: props.$textTransform, + ...(props.$whiteSpace ? { whiteSpace: props.$whiteSpace } : {}), }) ); export interface LinkProps extends Pick { textTransform?: TextTransform; + whiteSpace?: React.CSSProperties["whiteSpace"]; target?: React.AnchorHTMLAttributes["target"]; } @@ -33,6 +36,7 @@ export const Link = ({ textTransform = "none", target = "_blank", children, + whiteSpace, ...props }: LinkProps) => ( {children} diff --git a/frontend/packages/core/src/utils/index.ts b/frontend/packages/core/src/utils/index.ts index b42b50a982..4aa7bbf944 100644 --- a/frontend/packages/core/src/utils/index.ts +++ b/frontend/packages/core/src/utils/index.ts @@ -1,3 +1,2 @@ -// eslint-disable-next-line import/prefer-default-export export { default as getDisplayName } from "./getDisplayName"; export { default as findPathMatchList } from "./pathMatching"; diff --git a/frontend/workflows/projectCatalog/src/catalog/index.tsx b/frontend/workflows/projectCatalog/src/catalog/index.tsx index 7fb9dc9527..173665774e 100644 --- a/frontend/workflows/projectCatalog/src/catalog/index.tsx +++ b/frontend/workflows/projectCatalog/src/catalog/index.tsx @@ -16,7 +16,7 @@ import RestoreIcon from "@mui/icons-material/Restore"; import SearchIcon from "@mui/icons-material/Search"; import { alpha, Box, CircularProgress } from "@mui/material"; -import type { WorkflowProps } from ".."; +import type { WorkflowProps } from "../types"; import catalogReducer from "./catalog-reducer"; import ProjectCard from "./project-card"; diff --git a/frontend/workflows/projectCatalog/src/config/index.tsx b/frontend/workflows/projectCatalog/src/config/index.tsx deleted file mode 100644 index 863be0c5d0..0000000000 --- a/frontend/workflows/projectCatalog/src/config/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from "react"; -import type { clutch as IClutch } from "@clutch-sh/api"; -import type { ClutchError } from "@clutch-sh/core"; -import { - Error, - Grid, - styled, - Tab, - Tabs, - useLocation, - useNavigate, - useParams, -} from "@clutch-sh/core"; - -import type { ProjectConfigPage, ProjectDetailsConfigWorkflowProps } from ".."; -import { ProjectDetailsContext } from "../details/context"; -import ProjectHeader from "../details/header"; -import { fetchProjectInfo } from "../details/helpers"; - -const StyledContainer = styled(Grid)({ - padding: "32px", -}); - -const Config: React.FC = ({ children, defaultRoute = "/" }) => { - const { projectId, configType = defaultRoute } = useParams(); - const location = useLocation(); - const navigate = useNavigate(); - const [projectInfo, setProjectInfo] = React.useState( - null - ); - const [error, setError] = React.useState(null); - const [configPages, setConfigPages] = React.useState([]); - const [selectedPage, setSelectedPage] = React.useState(0); - const projInfo = React.useMemo(() => ({ projectInfo }), [projectInfo]); - - React.useEffect(() => { - fetchProjectInfo(projectId).then(setProjectInfo).catch(setError); - }, []); - - React.useEffect(() => { - if (configPages && configPages.length) { - const splitLoc = location.pathname.split("/"); - const selectedPath = configPages[selectedPage]?.props?.path; - - if (selectedPath) { - if (splitLoc[splitLoc.length - 1] !== "config") { - splitLoc.splice(splitLoc.length - 1, 1, selectedPath); - } else { - splitLoc.push(selectedPath); - } - - // Used to reduce the number of navigation calls when the user is navigating between tabs - if (splitLoc.join("/") !== location.pathname.replace(/%20/, " ")) { - navigate( - { - pathname: splitLoc.join("/"), - search: window.location.search, - }, - { replace: true } - ); - } - } - } - }, [configPages, selectedPage]); - - React.useEffect(() => { - if (children) { - const validPages: ProjectConfigPage[] = []; - - React.Children.forEach(children, (child, index) => { - if (React.isValidElement(child)) { - const { title, path, onError } = child?.props || {}; - - if (title) { - validPages.push( - React.cloneElement(child, { - onError: (e: ClutchError) => { - if (onError) { - onError(e); - } - setError(e); - }, - }) - ); - - if (configType === path) { - setSelectedPage(index); - } - } - } - }); - - setConfigPages(validPages); - } - }, [children]); - - return ( - - - - - - {configPages && configPages.length > 1 ? ( - - - {configPages.map((page, i) => ( - setSelectedPage(i)} - /> - ))} - - - ) : null} - {error && ( - - - - )} - - {configPages && configPages.length > 0 && configPages[selectedPage]} - - - - ); -}; - -export default Config; diff --git a/frontend/workflows/projectCatalog/src/details/components/breadcrumbs.tsx b/frontend/workflows/projectCatalog/src/details/components/breadcrumbs.tsx new file mode 100644 index 0000000000..724742caf7 --- /dev/null +++ b/frontend/workflows/projectCatalog/src/details/components/breadcrumbs.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Typography, useTheme } from "@clutch-sh/core"; +import { alpha, Breadcrumbs, Link } from "@mui/material"; + +interface Route { + title: string; + path?: string; +} + +export interface BreadCrumbsProps { + routes?: Route[]; +} + +const BreadCrumbs = ({ routes = [] }: BreadCrumbsProps) => { + const theme = useTheme(); + routes.unshift({ title: "Project Catalog", path: "/catalog" }); + + let builtRoute = routes[0].path; + + const buildCrumb = (route: Route) => { + if (route?.path && route?.path !== builtRoute) { + builtRoute += `/${route.path}`; + } + + return ( + + {route.path ? ( + + {route.title} + + ) : ( + route.title + )} + + ); + }; + + return {routes.map(buildCrumb)}; +}; + +export default BreadCrumbs; diff --git a/frontend/workflows/projectCatalog/src/details/card.tsx b/frontend/workflows/projectCatalog/src/details/components/card.tsx similarity index 74% rename from frontend/workflows/projectCatalog/src/details/card.tsx rename to frontend/workflows/projectCatalog/src/details/components/card.tsx index 46e4fe3446..fad6f19720 100644 --- a/frontend/workflows/projectCatalog/src/details/card.tsx +++ b/frontend/workflows/projectCatalog/src/details/components/card.tsx @@ -1,9 +1,10 @@ import React from "react"; import type { TypographyProps } from "@clutch-sh/core"; import { Card, ClutchError, Error, Grid, styled, Typography } from "@clutch-sh/core"; +import type { GridProps } from "@mui/material"; import { LinearProgress, Theme } from "@mui/material"; -enum CardType { +export enum CardType { DYNAMIC = "Dynamic", METADATA = "Metadata", } @@ -15,7 +16,9 @@ export interface CatalogDetailsCard { interface CardTitleProps { title?: string | Element | React.ReactNode; titleVariant?: TypographyProps["variant"]; + titleAlign?: GridProps["alignItems"]; titleIcon?: React.ReactNode; + titleIconAlign?: GridProps["alignItems"]; endAdornment?: React.ReactNode; } @@ -46,10 +49,14 @@ interface CardProps extends CatalogDetailsCard, BaseCardProps {} const StyledCard = styled(Card)({ width: "100%", - height: "fit-content", + height: "100%", padding: "16px", }); +const StyledGrid = styled(Grid)({ + height: "fit-content", +}); + const StyledProgressContainer = styled("div")(({ theme }: { theme: Theme }) => ({ marginBottom: "8px", marginTop: "-12px", @@ -66,30 +73,35 @@ const StyledTitle = styled(Grid)({ textTransform: "capitalize", }); -const StyledTitleContainer = styled(Grid)({ - marginBottom: "8px", -}); - -const BodyContainer = styled("div")({ - paddingLeft: "4px", -}); - -const CardTitle = ({ title, titleVariant = "h4", titleIcon, endAdornment }: CardTitleProps) => ( - +const CardTitle = ({ + title, + titleVariant = "h4", + titleAlign = "flex-start", + titleIcon, + titleIconAlign = "center", + endAdornment, +}: CardTitleProps) => ( + {title && ( - + {titleIcon && {titleIcon}} {title} - + )} {endAdornment && ( ( - <> + {loadingIndicator && loading && ( - - - + + + + + )} - {error ? : children} - + + <>{error ? : children} + + ); const BaseCard = ({ loading, error, ...props }: CardProps) => { @@ -153,8 +169,14 @@ const BaseCard = ({ loading, error, ...props }: CardProps) => { return ( - - + + + + + + + + ); }; @@ -163,4 +185,6 @@ const DynamicCard = (props: BaseCardProps) => ; -export { CardType, DynamicCard, MetaCard }; +export type DetailCard = CatalogDetailsCard | typeof DynamicCard | typeof MetaCard; + +export { DynamicCard, MetaCard }; diff --git a/frontend/workflows/projectCatalog/src/details/components/header.tsx b/frontend/workflows/projectCatalog/src/details/components/header.tsx new file mode 100644 index 0000000000..e275d0aee7 --- /dev/null +++ b/frontend/workflows/projectCatalog/src/details/components/header.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Grid, styled, Typography } from "@clutch-sh/core"; + +export interface ProjectHeaderProps { + title?: string; + description?: string; +} + +const StyledContainer = styled(Grid)({ + width: "100%", + height: "100%", +}); + +const ProjectHeader = ({ title, description = "" }: ProjectHeaderProps) => ( + + + + {title} + + + {description.length > 0 && ( + + {description} + + )} + +); + +export default ProjectHeader; diff --git a/frontend/workflows/projectCatalog/src/details/components/layout.tsx b/frontend/workflows/projectCatalog/src/details/components/layout.tsx new file mode 100644 index 0000000000..589eb37521 --- /dev/null +++ b/frontend/workflows/projectCatalog/src/details/components/layout.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import type { clutch as IClutch } from "@clutch-sh/api"; +import { Grid, QuickLinkGroup, styled, useNavigate, useParams } from "@clutch-sh/core"; + +import type { ProjectDetailsWorkflowProps } from "../../types"; +import { ProjectDetailsContext } from "../context"; +import fetchProjectInfo from "../resolver"; + +import type { BreadCrumbsProps } from "./breadcrumbs"; +import BreadCrumbs from "./breadcrumbs"; +import ProjectHeader, { ProjectHeaderProps } from "./header"; +import QuickLinksAndSettings from "./link-settings"; + +export interface CatalogLayoutProps + extends BreadCrumbsProps, + ProjectHeaderProps, + Pick { + children?: React.ReactNode; + quickLinkSettings?: boolean; +} + +const StyledContainer = styled(Grid)({ + padding: "8px 24px", +}); + +const CatalogLayout = ({ + routes = [], + title, + description, + configLinks = [], + children, + allowDisabled, + quickLinkSettings = true, +}: CatalogLayoutProps) => { + const { projectId } = useParams(); + const navigate = useNavigate(); + const [projectInfo, setProjectInfo] = React.useState( + null + ); + const projInfo = React.useMemo(() => ({ projectId, projectInfo }), [projectId, projectInfo]); + + const redirectNotFound = () => navigate(`/${projectId}/notFound`, { replace: true }); + + const fetchData = () => + fetchProjectInfo(projectId, allowDisabled) + .then(data => { + if (!data) { + redirectNotFound(); + return; + } + setProjectInfo(data as IClutch.core.project.v1.IProject); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error(err); + }); + + React.useEffect(() => { + fetchData(); + + const interval = setInterval(fetchData, 30000); + + return () => (interval ? clearInterval(interval) : undefined); + }, []); + + return ( + + + + + + + + + + + + + + {projectInfo && ( + + )} + + + + + {children && children} + + + + ); +}; + +export default CatalogLayout; diff --git a/frontend/workflows/projectCatalog/src/details/components/link-settings.tsx b/frontend/workflows/projectCatalog/src/details/components/link-settings.tsx new file mode 100644 index 0000000000..91602274bb --- /dev/null +++ b/frontend/workflows/projectCatalog/src/details/components/link-settings.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { useParams } from "react-router-dom"; +import { + checkFeatureEnabled, + Grid, + IconButton, + Link, + Popper, + PopperItem, + QuickLinkGroup, + QuickLinksCard, + styled, + Typography, +} from "@clutch-sh/core"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { isEmpty } from "lodash"; + +import type { ProjectConfigLink } from "../../types"; + +interface QuickLinksAndSettingsProps { + linkGroups: QuickLinkGroup[]; + configLinks?: ProjectConfigLink[]; + showSettings: boolean; +} + +const StyledPopperItem = styled(PopperItem)({ + "&&&": { + height: "auto", + }, + "& span.MuiTypography-root": { + padding: "0", + }, + "& a.MuiTypography-root": { + padding: "4px 16px", + }, +}); + +const QuickLinksAndSettings = ({ + linkGroups, + configLinks = [], + showSettings, +}: QuickLinksAndSettingsProps) => { + const { projectId } = useParams(); + const anchorRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [links, setLinks] = React.useState(configLinks); + + React.useEffect(() => { + const projectConfigFlag = checkFeatureEnabled({ feature: "projectCatalogSettings" }); + if (projectConfigFlag) { + setLinks([ + { + title: "Project Configuration", + path: `/catalog/${projectId}/config`, + icon: , + }, + ...links, + ]); + } + }, []); + + return ( + + {!isEmpty(linkGroups) && ( + + + + )} + {showSettings && links && links.length > 0 && ( + + setOpen(o => !o)} size="medium"> + + + setOpen(false)} + placement="bottom-end" + > + {links.map(link => ( + + + + {link.icon && {link.icon}} + + + {link.title} + + + + + + ))} + + + )} + + ); +}; + +export default QuickLinksAndSettings; diff --git a/frontend/workflows/projectCatalog/src/details/config/index.tsx b/frontend/workflows/projectCatalog/src/details/config/index.tsx new file mode 100644 index 0000000000..011bbd972f --- /dev/null +++ b/frontend/workflows/projectCatalog/src/details/config/index.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import type { ClutchError } from "@clutch-sh/core"; +import { Error, Grid, Tab, Tabs, useLocation, useNavigate, useParams } from "@clutch-sh/core"; + +import type { ProjectCatalogProps, WorkflowProps } from "../../types"; +import CatalogLayout from "../components/layout"; + +export interface ProjectConfigProps { + title: string; + path: string; + onError?: (error: ClutchError) => void; +} + +export type ProjectConfigPage = React.ReactElement; + +export interface ProjectDetailsConfigWorkflowProps extends WorkflowProps, ProjectCatalogProps { + children?: ProjectConfigPage | ProjectConfigPage[]; + // eslint-disable-next-line react/no-unused-prop-types + description?: string; + defaultRoute?: string; +} + +const Config = ({ children, defaultRoute = "/" }: ProjectDetailsConfigWorkflowProps) => { + const { configType = defaultRoute } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + const [error, setError] = React.useState(null); + const [configPages, setConfigPages] = React.useState([]); + const [selectedPage, setSelectedPage] = React.useState(0); + + React.useEffect(() => { + if (configPages && configPages.length) { + const splitLoc = location.pathname.split("/"); + const selectedPath = configPages[selectedPage]?.props?.path; + + if (selectedPath) { + if (splitLoc[splitLoc.length - 1] !== "config") { + splitLoc.splice(splitLoc.length - 1, 1, selectedPath); + } else { + splitLoc.push(selectedPath); + } + + // Used to reduce the number of navigation calls when the user is navigating between tabs + if (splitLoc.join("/") !== location.pathname.replace(/%20/, " ")) { + navigate( + { + pathname: splitLoc.join("/"), + search: window.location.search, + }, + { replace: true } + ); + } + } + } + }, [configPages, selectedPage]); + + React.useEffect(() => { + if (children) { + const validPages: ProjectConfigPage[] = []; + + React.Children.forEach(children, (child, index) => { + if (React.isValidElement(child)) { + const { title, path, onError } = child?.props || {}; + + if (title) { + validPages.push( + React.cloneElement(child, { + onError: (e: ClutchError) => { + if (onError) { + onError(e); + } + setError(e); + }, + }) + ); + + if (configType === path) { + setSelectedPage(index); + } + } + } + }); + + setConfigPages(validPages); + } + }, [children]); + + return ( + <> + {configPages && configPages.length > 1 ? ( + + + {configPages.map((page, i) => ( + setSelectedPage(i)} + /> + ))} + + + ) : null} + {error && ( + + + + )} + + {configPages && configPages.length > 0 && configPages[selectedPage]} + + + ); +}; + +const CatalogConfigPage = ({ + defaultRoute = "/", + description = "", + ...props +}: ProjectDetailsConfigWorkflowProps) => { + const { configType = defaultRoute } = useParams(); + + return ( + + + + ); +}; + +export default CatalogConfigPage; diff --git a/frontend/workflows/projectCatalog/src/details/context.tsx b/frontend/workflows/projectCatalog/src/details/context.tsx index f3121d3d8c..b7a1f794ee 100644 --- a/frontend/workflows/projectCatalog/src/details/context.tsx +++ b/frontend/workflows/projectCatalog/src/details/context.tsx @@ -3,6 +3,7 @@ import type { clutch as IClutch } from "@clutch-sh/api"; interface ContextProps { projectInfo: IClutch.core.project.v1.IProject | null; + projectId: string | null; } const ProjectDetailsContext = React.createContext(undefined); diff --git a/frontend/workflows/projectCatalog/src/details/header.tsx b/frontend/workflows/projectCatalog/src/details/header.tsx deleted file mode 100644 index 2e5e071d57..0000000000 --- a/frontend/workflows/projectCatalog/src/details/header.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from "react"; -import { Grid, styled, Typography, useTheme } from "@clutch-sh/core"; -import { alpha, Breadcrumbs, Link } from "@mui/material"; - -interface Route { - title: string; - path?: string; -} - -interface BreadCrumbProps { - routes: Route[]; -} - -interface ProjectHeaderProps extends BreadCrumbProps { - title: string; - description?: string; -} - -const StyledHeading = styled("div")({ - padding: "8px 0px 8px 0px", - textTransform: "capitalize", -}); - -const StyledContainer = styled(Grid)({ - width: "100%", - height: "100%", -}); - -const StyledCrumb = styled(Typography)({ - textTransform: "uppercase", -}); - -const BreadCrumbs = ({ routes = [] }: BreadCrumbProps) => { - const theme = useTheme(); - routes.unshift({ title: "Project Catalog", path: "/catalog" }); - - let builtRoute = routes[0].path; - - const buildCrumb = (route: Route) => { - if (route?.path && route?.path !== builtRoute) { - builtRoute += `/${route.path}`; - } - - return ( - - {route.path ? ( - - {route.title} - - ) : ( - route.title - )} - - ); - }; - - return {routes.map(buildCrumb)}; -}; - -const ProjectHeader = ({ title, routes, description = "" }: ProjectHeaderProps) => ( - - - - - - {title} - - {description.length > 0 && {description}} - -); - -export default ProjectHeader; diff --git a/frontend/workflows/projectCatalog/src/details/helpers.tsx b/frontend/workflows/projectCatalog/src/details/helpers.tsx deleted file mode 100644 index 4ec9add3aa..0000000000 --- a/frontend/workflows/projectCatalog/src/details/helpers.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import type { clutch as IClutch } from "@clutch-sh/api"; -import { client, Grid, Link, styled, TimeAgo as EventTime, Typography } from "@clutch-sh/core"; -import { faClock } from "@fortawesome/free-regular-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import * as _ from "lodash"; - -const StyledLink = styled(Link)({ - whiteSpace: "nowrap", -}); - -const fetchProjectInfo = ( - project: string, - allowDisabled: boolean = false -): Promise => - client - .post("/v1/project/getProjects", { projects: [project], excludeDependencies: true }) - .then(resp => { - const { results = {}, partialFailures } = resp.data as IClutch.project.v1.GetProjectsResponse; - - const projectResults = _.get(results, [project, "project"], {}); - - if (_.isEmpty(projectResults) && allowDisabled && partialFailures) { - // Will add disabled projects to the list of projects to display if requested - const failuresMap = partialFailures - .map(p => { - if ((_.get(p, "message", "") ?? "").includes("disabled")) { - const disabledProject = _.get(p, "details.[0]"); - _.set( - disabledProject, - ["data", "description"], - `${disabledProject.name} is disabled.` - ); - return disabledProject; - } - return null; - }) - .filter(Boolean); - - if (failuresMap.length) { - return failuresMap[0]; - } - } - - return projectResults; - }); - -const LinkText = ({ text, link }: { text: string; link?: string }) => { - const returnText = {text}; - - if (link && text) { - return {returnText}; - } - - return returnText; -}; - -const LastEvent = ({ time, ...props }: { time: number }) => { - return time ? ( - <> - - - - - - ago - - - - ) : null; -}; - -export { fetchProjectInfo, LastEvent, LinkText }; diff --git a/frontend/workflows/projectCatalog/src/details/index.tsx b/frontend/workflows/projectCatalog/src/details/index.tsx index 5880725c0c..c1802124bc 100644 --- a/frontend/workflows/projectCatalog/src/details/index.tsx +++ b/frontend/workflows/projectCatalog/src/details/index.tsx @@ -1,57 +1,20 @@ import React from "react"; -import { useParams } from "react-router-dom"; -import type { clutch as IClutch } from "@clutch-sh/api"; -import { - checkFeatureEnabled, - Grid, - IconButton, - Link, - Popper, - PopperItem, - QuickLinkGroup, - QuickLinksCard, - styled, - Tooltip, - Typography, -} from "@clutch-sh/core"; +import { Grid, Tooltip } from "@clutch-sh/core"; import CodeOffIcon from "@mui/icons-material/CodeOff"; import GroupIcon from "@mui/icons-material/Group"; -import SettingsIcon from "@mui/icons-material/Settings"; -import { capitalize, isEmpty } from "lodash"; +import { capitalize, defaultsDeep } from "lodash"; -import type { CatalogDetailsChild, ProjectConfigLink, ProjectDetailsWorkflowProps } from ".."; +import type { + CatalogDetailsChild, + DetailsLayoutOptions, + ProjectDetailsWorkflowProps, +} from "../types"; -import { CardType, DynamicCard, MetaCard } from "./card"; -import { ProjectDetailsContext } from "./context"; -import ProjectHeader from "./header"; -import { fetchProjectInfo } from "./helpers"; +import { CardType, DynamicCard, MetaCard } from "./components/card"; +import CatalogLayout from "./components/layout"; +import { useProjectDetailsContext } from "./context"; import ProjectInfoCard from "./info"; -interface QuickLinksAndSettingsProps { - linkGroups: QuickLinkGroup[]; - configLinks?: ProjectConfigLink[]; -} - -const StyledContainer = styled(Grid)({ - padding: "16px", -}); - -const StyledHeadingContainer = styled(Grid)({ - marginBottom: "24px", -}); - -const StyledPopperItem = styled(PopperItem)({ - "&&&": { - height: "auto", - }, - "& span.MuiTypography-root": { - padding: "0", - }, - "& a.MuiTypography-root": { - padding: "4px 16px", - }, -}); - const DisabledItem = ({ name }: { name: string }) => ( @@ -60,87 +23,31 @@ const DisabledItem = ({ name }: { name: string }) => ( ); -const QuickLinksAndSettingsBtn = ({ linkGroups, configLinks = [] }: QuickLinksAndSettingsProps) => { - const { projectId } = useParams(); - const anchorRef = React.useRef(null); - const [open, setOpen] = React.useState(false); - const [links, setLinks] = React.useState(configLinks); - - React.useEffect(() => { - const projectConfigFlag = checkFeatureEnabled({ feature: "projectCatalogSettings" }); - if (projectConfigFlag) { - setLinks([ - { - title: "Project Configuration", - path: `/catalog/${projectId}/config`, - icon: , - }, - ...links, - ]); - } - }, []); - - return ( - - {!isEmpty(linkGroups) && ( - - - - )} - {links && links.length > 0 && ( - - setOpen(o => !o)} size="medium"> - - - setOpen(false)} - placement="bottom-end" - > - {links.map(link => ( - - - - {link.icon && {link.icon}} - - - {link.title} - - - - - - ))} - - - )} - - ); +const defaultLayout: DetailsLayoutOptions = { + metadata: { + direction: "column", + flexWrap: "nowrap", + spacing: 2, + xs: 12, + lg: 4, + xl: 3, + }, + dynamic: { + direction: "column", + flexWrap: "nowrap", + spacing: 2, + xs: 12, + lg: 8, + xl: 9, + }, }; -const Details: React.FC = ({ - children, - chips, - allowDisabled, - configLinks = [], -}) => { - const { projectId } = useParams(); - const [projectInfo, setProjectInfo] = React.useState( - null - ); +const Details = ({ children, chips, layout }: ProjectDetailsWorkflowProps) => { + const { projectId, projectInfo } = useProjectDetailsContext() || {}; const [metaCards, setMetaCards] = React.useState([]); const [dynamicCards, setDynamicCards] = React.useState([]); - const projInfo = React.useMemo(() => ({ projectInfo }), [projectInfo]); + + const layoutOptions: DetailsLayoutOptions = defaultsDeep(layout, defaultLayout); React.useEffect(() => { if (children) { @@ -153,16 +60,16 @@ const Details: React.FC = ({ switch (type) { case CardType.METADATA: - tempMetaCards.push(child); + tempMetaCards.push(child as CatalogDetailsChild); break; case CardType.DYNAMIC: - tempDynamicCards.push(child); + tempDynamicCards.push(child as CatalogDetailsChild); break; default: { if (child.type === DynamicCard) { - tempDynamicCards.push(child); + tempDynamicCards.push(child as CatalogDetailsChild); } else if (child.type === MetaCard) { - tempMetaCards.push(child); + tempMetaCards.push(child as CatalogDetailsChild); } // Do nothing, invalid card } @@ -193,70 +100,43 @@ const Details: React.FC = ({ }; return ( - - - {/* Column for project details and header */} - - - - {/* Static Header */} - - - {projectInfo && ( - - - - )} - - - - - {/* Static Info Card */} - } - fetchDataFn={() => fetchProjectInfo(projectId, allowDisabled)} - onSuccess={(data: unknown) => - setProjectInfo(data as IClutch.core.project.v1.IProject) - } - autoReload - loadingIndicator={false} - endAdornment={ - projectInfo?.data?.disabled ? : null - } - > - {projectInfo && } - - - {/* Custom Meta Cards */} - {metaCards.length > 0 && - metaCards.map(card => ( - - {card} - - ))} - - - {/* Custom Dynamic Cards */} - {dynamicCards.length > 0 && - dynamicCards.map(card => ( - - {card} - - ))} - - + <> + + + {projectInfo && ( + } + loadingIndicator={false} + endAdornment={ + projectInfo?.data?.disabled ? ( + + ) : null + } + > + {projectInfo && } + + )} - - + {metaCards.length > 0 && metaCards.map(card => {card})} + + + {dynamicCards.length > 0 && dynamicCards.map(card => {card})} + + + ); +}; + +const CatalogDetailsPage = ({ + allowDisabled, + configLinks, + ...props +}: ProjectDetailsWorkflowProps) => { + return ( + +
+ ); }; -export default Details; +export default CatalogDetailsPage; diff --git a/frontend/workflows/projectCatalog/src/details/info/messengerRow.tsx b/frontend/workflows/projectCatalog/src/details/info/messengerRow.tsx index 31e5927407..5475185deb 100644 --- a/frontend/workflows/projectCatalog/src/details/info/messengerRow.tsx +++ b/frontend/workflows/projectCatalog/src/details/info/messengerRow.tsx @@ -6,7 +6,7 @@ import { faSlack } from "@fortawesome/free-brands-svg-icons"; import { faComment } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { LinkText } from "../helpers"; +import { LinkText } from "../../helpers"; const MessengerRow = ({ projectData }: { projectData: IClutch.core.project.v1.IProject }) => { const [text, setText] = React.useState(); diff --git a/frontend/workflows/projectCatalog/src/details/info/repositoryRow.tsx b/frontend/workflows/projectCatalog/src/details/info/repositoryRow.tsx index 5ce11a6b30..ab5737dcb1 100644 --- a/frontend/workflows/projectCatalog/src/details/info/repositoryRow.tsx +++ b/frontend/workflows/projectCatalog/src/details/info/repositoryRow.tsx @@ -5,7 +5,7 @@ import { faGithub } from "@fortawesome/free-brands-svg-icons"; import { faCode, faCodeBranch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { LinkText } from "../helpers"; +import { LinkText } from "../../helpers"; interface ProjectPullRequests { number: number; diff --git a/frontend/workflows/projectCatalog/src/details/resolver.tsx b/frontend/workflows/projectCatalog/src/details/resolver.tsx new file mode 100644 index 0000000000..668d5827ea --- /dev/null +++ b/frontend/workflows/projectCatalog/src/details/resolver.tsx @@ -0,0 +1,44 @@ +import type { clutch as IClutch } from "@clutch-sh/api"; +import { client } from "@clutch-sh/core"; +import * as _ from "lodash"; + +const fetchProjectInfo = ( + project: string, + allowDisabled: boolean = false +): Promise => + client + .post("/v1/project/getProjects", { + projects: [project], + excludeDependencies: true, + } as IClutch.project.v1.GetProjectsRequest) + .then(resp => { + const { results = {}, partialFailures } = resp.data as IClutch.project.v1.GetProjectsResponse; + + const projectResults = _.get(results, [project, "project"], {}); + + if (_.isEmpty(projectResults) && allowDisabled && partialFailures) { + // Will add disabled projects to the list of projects to display if requested + const failuresMap = partialFailures + .map(p => { + if ((_.get(p, "message", "") ?? "").includes("disabled")) { + const disabledProject = _.get(p, "details.[0]", { name: "unknown " }); + _.set( + disabledProject, + ["data", "description"], + `${disabledProject.name} is disabled.` + ); + return disabledProject; + } + return null; + }) + .filter(Boolean); + + if (failuresMap.length) { + return failuresMap[0]; + } + } + + return projectResults as IClutch.core.project.v1.IProject; + }); + +export default fetchProjectInfo; diff --git a/frontend/workflows/projectCatalog/src/helpers/index.ts b/frontend/workflows/projectCatalog/src/helpers/index.ts new file mode 100644 index 0000000000..84af78a9f2 --- /dev/null +++ b/frontend/workflows/projectCatalog/src/helpers/index.ts @@ -0,0 +1,3 @@ +export { default as LanguageIcon } from "./language-icon"; +export { default as LastEvent } from "./last-event"; +export { default as LinkText } from "./link-text"; diff --git a/frontend/workflows/projectCatalog/src/helpers/last-event.tsx b/frontend/workflows/projectCatalog/src/helpers/last-event.tsx new file mode 100644 index 0000000000..84ba247f2f --- /dev/null +++ b/frontend/workflows/projectCatalog/src/helpers/last-event.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Grid, TimeAgo as EventTime, Typography, TypographyProps } from "@clutch-sh/core"; +import { faClock } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon, FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; +import type { GridProps } from "@mui/material"; + +interface Size { + variant: TypographyProps["variant"]; + icon: FontAwesomeIconProps["size"]; + align: GridProps["alignItems"]; +} + +interface SizeMap { + [key: string]: Size; +} + +const SIZE_MAP: SizeMap = { + small: { + variant: "body4", + icon: "sm", + align: "center", + }, + medium: { + variant: "body3", + icon: "1x", + align: "flex-end", + }, + large: { + variant: "body2", + icon: "lg", + align: "flex-end", + }, +}; + +interface LastEventProps { + time: number; + size?: keyof SizeMap; +} + +const LastEvent = ({ time, size = "small", ...props }: LastEventProps) => { + return time ? ( + + + + + + + + ago + + + + + ) : null; +}; + +export default LastEvent; diff --git a/frontend/workflows/projectCatalog/src/helpers/link-text.tsx b/frontend/workflows/projectCatalog/src/helpers/link-text.tsx new file mode 100644 index 0000000000..575a581aa3 --- /dev/null +++ b/frontend/workflows/projectCatalog/src/helpers/link-text.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Link, Typography } from "@clutch-sh/core"; + +const LinkText = ({ text, link }: { text: string; link?: string }) => { + const returnText = {text}; + + if (link && text) { + return ( + + {returnText} + + ); + } + + return returnText; +}; + +export default LinkText; diff --git a/frontend/workflows/projectCatalog/src/index.tsx b/frontend/workflows/projectCatalog/src/index.tsx index e0da7abd33..39bd6a4d08 100644 --- a/frontend/workflows/projectCatalog/src/index.tsx +++ b/frontend/workflows/projectCatalog/src/index.tsx @@ -1,47 +1,9 @@ -import type { BaseWorkflowProps, ClutchError, WorkflowConfiguration } from "@clutch-sh/core"; +import type { WorkflowConfiguration } from "@clutch-sh/core"; -import type { CatalogDetailsCard } from "./details/card"; -import { CardType, DynamicCard, MetaCard } from "./details/card"; -import type { ProjectInfoChip } from "./details/info/chipsRow"; +import Config from "./details/config"; import Catalog from "./catalog"; -import Config from "./config"; import Details from "./details"; -type DetailCard = CatalogDetailsCard | typeof DynamicCard | typeof MetaCard; - -interface ProjectCatalogProps { - allowDisabled?: boolean; -} - -export interface ProjectConfigLink { - title: string; - path: string; - icon?: React.ReactElement; -} - -export interface ProjectConfigProps { - title: string; - path: string; - onError?: (error: ClutchError) => void; -} - -type CatalogDetailsChild = React.ReactElement; - -export type ProjectConfigPage = React.ReactElement; - -export interface WorkflowProps extends BaseWorkflowProps, ProjectCatalogProps {} - -export interface ProjectDetailsWorkflowProps extends WorkflowProps, ProjectCatalogProps { - children?: CatalogDetailsChild | CatalogDetailsChild[]; - chips?: ProjectInfoChip[]; - configLinks?: ProjectConfigLink[]; -} - -export interface ProjectDetailsConfigWorkflowProps extends WorkflowProps, ProjectCatalogProps { - children?: ProjectConfigPage | ProjectConfigPage[]; - defaultRoute?: string; -} - const register = (): WorkflowConfiguration => { return { developer: { @@ -81,10 +43,13 @@ const register = (): WorkflowConfiguration => { }; }; -export { CardType, DynamicCard, MetaCard }; -export { LastEvent } from "./details/helpers"; +export * from "./helpers"; +export { CardType, DynamicCard, MetaCard } from "./details/components/card"; export { useProjectDetailsContext } from "./details/context"; export { Details as ProjectDetails, Config as ProjectConfig }; -export type { CatalogDetailsCard, CatalogDetailsChild, ProjectInfoChip }; +export type { CatalogDetailsCard } from "./details/components/card"; +export type { ProjectInfoChip } from "./details/info/chipsRow"; +export type { CatalogDetailsChild, ProjectConfigLink, DetailsLayoutOptions } from "./types"; +export type { ProjectConfigProps } from "./details/config"; export default register; diff --git a/frontend/workflows/projectCatalog/src/types/config.ts b/frontend/workflows/projectCatalog/src/types/config.ts new file mode 100644 index 0000000000..96d8471e92 --- /dev/null +++ b/frontend/workflows/projectCatalog/src/types/config.ts @@ -0,0 +1,5 @@ +export interface ProjectConfigLink { + title: string; + path: string; + icon?: React.ReactElement; +} diff --git a/frontend/workflows/projectCatalog/src/types/details.ts b/frontend/workflows/projectCatalog/src/types/details.ts new file mode 100644 index 0000000000..ec6b7114bd --- /dev/null +++ b/frontend/workflows/projectCatalog/src/types/details.ts @@ -0,0 +1,24 @@ +import type { GridProps } from "@mui/material"; + +import type { DetailCard } from "../details/components/card"; +import type { ProjectInfoChip } from "../details/info/chipsRow"; + +import type { ProjectConfigLink } from "./config"; +import type { ProjectCatalogProps, WorkflowProps } from "./workflow"; + +export interface DetailsLayoutOptions { + metadata?: GridProps; + dynamic?: GridProps; +} + +export interface ProjectDetailsWorkflowProps extends WorkflowProps, ProjectCatalogProps { + children?: + | ((CatalogDetailsChild | CatalogDetailsChild[]) & + (React.ReactChild | React.ReactFragment | React.ReactPortal | null)) + | undefined; + chips?: ProjectInfoChip[]; + configLinks?: ProjectConfigLink[]; + layout?: DetailsLayoutOptions; +} + +export type CatalogDetailsChild = React.ReactElement; diff --git a/frontend/workflows/projectCatalog/src/types/index.ts b/frontend/workflows/projectCatalog/src/types/index.ts new file mode 100644 index 0000000000..101599840f --- /dev/null +++ b/frontend/workflows/projectCatalog/src/types/index.ts @@ -0,0 +1,3 @@ +export * from "./config"; +export * from "./details"; +export * from "./workflow"; diff --git a/frontend/workflows/projectCatalog/src/types/workflow.ts b/frontend/workflows/projectCatalog/src/types/workflow.ts new file mode 100644 index 0000000000..fa415d1b22 --- /dev/null +++ b/frontend/workflows/projectCatalog/src/types/workflow.ts @@ -0,0 +1,7 @@ +import type { BaseWorkflowProps } from "@clutch-sh/core"; + +export interface ProjectCatalogProps { + allowDisabled?: boolean; +} + +export interface WorkflowProps extends BaseWorkflowProps, ProjectCatalogProps {}