From 989cf9bb487c321f552ed9c6cb52f3d082327ee3 Mon Sep 17 00:00:00 2001 From: CristhianF7 <CristhianF7@gmail.com> Date: Wed, 17 May 2023 18:41:55 -0500 Subject: [PATCH 1/6] feat: marketplace --- components/card/card.styled.ts | 2 +- components/checkbox/index.tsx | 2 +- .../gitProviderButton.styled.ts | 2 +- .../installationStepContainer/index.tsx | 4 +- components/marketplaceCard/index.tsx | 50 +++++++ .../marketplaceCard/marketplaceCard.styled.ts | 32 +++++ components/marketplaceModal/index.tsx | 76 +++++++++++ .../marketplaceModal.styled.ts | 32 +++++ components/modal/index.tsx | 7 +- components/password/index.tsx | 21 +-- components/service/index.tsx | 6 + components/service/service.styled.ts | 2 +- components/tab/index.tsx | 4 +- components/textField/index.tsx | 4 +- containers/clusterManagement/index.tsx | 4 +- containers/conciseLogs/index.tsx | 10 +- containers/marketplace/index.tsx | 128 ++++++++++++++++++ containers/marketplace/marketplace.styled.ts | 31 +++++ containers/provision/index.tsx | 6 +- containers/services/index.tsx | 95 ++++++++++--- containers/services/services.styled.ts | 5 +- containers/terminalLogs/terminalLogs.tsx | 10 +- declaration.d.ts | 1 + next.config.js | 6 + pages/services.tsx | 3 + redux/api/index.ts | 1 + .../slices/{cluster.slice.ts => api.slice.ts} | 27 +++- redux/slices/index.ts | 2 +- redux/store.ts | 4 +- .../thunks/{cluster.thunk.ts => api.thunk.ts} | 34 ++++- theme/index.ts | 2 + types/marketplace/index.ts | 7 + 32 files changed, 534 insertions(+), 86 deletions(-) create mode 100644 components/marketplaceCard/index.tsx create mode 100644 components/marketplaceCard/marketplaceCard.styled.ts create mode 100644 components/marketplaceModal/index.tsx create mode 100644 components/marketplaceModal/marketplaceModal.styled.ts create mode 100644 containers/marketplace/index.tsx create mode 100644 containers/marketplace/marketplace.styled.ts rename redux/slices/{cluster.slice.ts => api.slice.ts} (82%) rename redux/thunks/{cluster.thunk.ts => api.thunk.ts} (81%) create mode 100644 types/marketplace/index.ts diff --git a/components/card/card.styled.ts b/components/card/card.styled.ts index 0e2968a4..039d6ccd 100644 --- a/components/card/card.styled.ts +++ b/components/card/card.styled.ts @@ -3,7 +3,7 @@ import styled, { css } from 'styled-components'; import { CardProps } from '.'; export const CardContainer = styled.div<CardProps>` - border: 2px solid #e2e8f0; + border: 2px solid ${({ theme }) => theme.colors.pastelLightBlue}; border-radius: 8px; background-color: white; cursor: pointer; diff --git a/components/checkbox/index.tsx b/components/checkbox/index.tsx index 6a45baae..787362d3 100644 --- a/components/checkbox/index.tsx +++ b/components/checkbox/index.tsx @@ -10,7 +10,7 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(p <CheckboxMUI {...props} inputRef={ref} - style={{ border: '1px' }} + style={{ border: '1px', padding: 0 }} icon={ <CheckBoxOutlineBlankIcon sx={{ borderRadius: '4px', color: props.disabled ? `${QUARTZ}` : `${SPUN_PEARL}` }} diff --git a/components/gitProviderButton/gitProviderButton.styled.ts b/components/gitProviderButton/gitProviderButton.styled.ts index 7b89f62a..43fef41c 100644 --- a/components/gitProviderButton/gitProviderButton.styled.ts +++ b/components/gitProviderButton/gitProviderButton.styled.ts @@ -6,7 +6,7 @@ export const Button = styled.button<{ active?: boolean }>` align-items: center; width: 260px; height: 84px; - border: 2px solid #e2e8f0; + border: 2px solid ${({ theme }) => theme.colors.pastelLightBlue}; border-radius: 8px; background-color: white; cursor: pointer; diff --git a/components/installationStepContainer/index.tsx b/components/installationStepContainer/index.tsx index fc375e8d..e46274a1 100644 --- a/components/installationStepContainer/index.tsx +++ b/components/installationStepContainer/index.tsx @@ -37,8 +37,8 @@ const InstallationStepContainer: FunctionComponent<InstallationStepContainerProp children, ...rest }) => { - const { completedSteps, isProvisioned } = useAppSelector(({ cluster }) => ({ - ...cluster, + const { completedSteps, isProvisioned } = useAppSelector(({ api }) => ({ + ...api, })); const progress = useMemo(() => { const clusterChecks = Object.keys(CLUSTER_CHECKS); diff --git a/components/marketplaceCard/index.tsx b/components/marketplaceCard/index.tsx new file mode 100644 index 00000000..634ac76a --- /dev/null +++ b/components/marketplaceCard/index.tsx @@ -0,0 +1,50 @@ +import React, { FunctionComponent, PropsWithChildren } from 'react'; +import Image from 'next/image'; + +import Tag from '../tag'; +import { MarketplaceApp } from '../../types/marketplace'; +import Button from '../../components/button'; +import Typography from '../typography'; +import { VOLCANIC_SAND } from '../../constants/colors'; + +import { Card, Description, Header } from './marketplaceCard.styled'; + +export interface MarketplaceCardProps extends MarketplaceApp { + onClick?: () => void; + showSubmitButton?: boolean; +} + +const MarketplaceCard: FunctionComponent<PropsWithChildren<MarketplaceCardProps>> = ({ + name, + categories, + image_url, + description, + onClick, + showSubmitButton = true, + children, +}) => { + return ( + <Card> + <Header> + <Image alt={name} height={28} width={28} src={image_url} style={{ objectFit: 'contain' }} /> + <Typography + variant="buttonSmall" + sx={{ textTransform: 'capitalize' }} + color={VOLCANIC_SAND} + > + {name} + </Typography> + {categories && + categories.map((category) => <Tag key={category} text={category} bgColor="purple" />)} + </Header> + <Description variant="body2">{description || children}</Description> + {showSubmitButton && ( + <Button variant="outlined" color="secondary" onClick={onClick}> + Add + </Button> + )} + </Card> + ); +}; + +export default MarketplaceCard; diff --git a/components/marketplaceCard/marketplaceCard.styled.ts b/components/marketplaceCard/marketplaceCard.styled.ts new file mode 100644 index 00000000..7d24ab6f --- /dev/null +++ b/components/marketplaceCard/marketplaceCard.styled.ts @@ -0,0 +1,32 @@ +import styled from 'styled-components'; + +import Typography from '../../components/typography'; + +export const Card = styled.div` + background-color: ${({ theme }) => theme.colors.white}; + border: 1px solid ${({ theme }) => theme.colors.pastelLightBlue}; + border-radius: 12px; + height: 194px; + padding: 24px; + width: 372px; +`; + +export const Description = styled(Typography)` + margin: 16px 0; + color: ${({ theme }) => theme.colors.saltboxBlue}; + + & a { + color: ${({ theme }) => theme.colors.primary}; + text-decoration: none; + } + + & a:hover { + text-decoration: underline; + } +`; + +export const Header = styled.div` + align-items: center; + display: flex; + gap: 16px; +`; diff --git a/components/marketplaceModal/index.tsx b/components/marketplaceModal/index.tsx new file mode 100644 index 00000000..fff0fe1e --- /dev/null +++ b/components/marketplaceModal/index.tsx @@ -0,0 +1,76 @@ +import React, { FunctionComponent } from 'react'; +import { Box, Divider } from '@mui/material'; +import Image from 'next/image'; +import { useForm } from 'react-hook-form'; + +import Modal from '../modal'; +import Button from '../button'; +import Typography from '../typography'; +import ControlledPassword from '../controlledFields/Password'; +import { MarketplaceApp } from '../../types/marketplace'; +import { BISCAY, SALTBOX_BLUE } from '../../constants/colors'; + +import { Content, Close, Footer, Header } from './marketplaceModal.styled'; + +export interface MarketplaceModalProps extends MarketplaceApp { + isOpen: boolean; + closeModal: () => void; +} + +const MarketplaceModal: FunctionComponent<MarketplaceModalProps> = ({ + closeModal, + isOpen, + name, + image_url, + secret_keys, +}) => { + const { control } = useForm(); + return ( + <Modal isOpen={isOpen} padding={0}> + <Box + sx={{ + width: '630px', + height: 'auto', + backgroundColor: 'white', + borderRadius: '8px', + boxShadow: '0px 2px 4px rgba(100, 116, 139, 0.1)', + }} + > + <Header> + <Image alt={name} src={image_url} width={30} height={30} /> + <Typography variant="h6" color={BISCAY}> + {name} + </Typography> + <Close onClick={closeModal} htmlColor={SALTBOX_BLUE} fontSize="medium" /> + </Header> + <Divider /> + <Content> + {secret_keys && + secret_keys.map((key) => ( + <ControlledPassword + key={key} + control={control} + name={key} + label={key} + rules={{ + required: true, + }} + required + /> + ))} + </Content> + <Divider /> + <Footer> + <Button variant="text" color="info" onClick={closeModal}> + Cancel + </Button> + <Button variant="contained" color="primary"> + Add + </Button> + </Footer> + </Box> + </Modal> + ); +}; + +export default MarketplaceModal; diff --git a/components/marketplaceModal/marketplaceModal.styled.ts b/components/marketplaceModal/marketplaceModal.styled.ts new file mode 100644 index 00000000..05686b5b --- /dev/null +++ b/components/marketplaceModal/marketplaceModal.styled.ts @@ -0,0 +1,32 @@ +import styled from 'styled-components'; +import CloseIcon from '@mui/icons-material/Close'; + +export const Content = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + padding: 32px 24px; +`; + +export const Close = styled(CloseIcon)` + cursor: pointer; + position: fixed; + top: 25px; + right: 25px; +`; + +export const Footer = styled.div` + display: flex; + gap: 16px; + justify-content: flex-end; + padding: 16px 24px; + width: calc(100% - 48px); +`; + +export const Header = styled.div` + display: flex; + gap: 16px; + padding: 24px; + position: relative; + text-transform: capitalize; +`; diff --git a/components/modal/index.tsx b/components/modal/index.tsx index 63e23fd5..09f9292a 100644 --- a/components/modal/index.tsx +++ b/components/modal/index.tsx @@ -9,7 +9,6 @@ const style = { transform: 'translate(-50%, -50%)', width: 'auto', borderRadius: '8px', - p: 4, zIndex: 2000, }; @@ -19,6 +18,7 @@ export interface IModalProps { children: React.ReactElement; isOpen: boolean; onCloseModal?: () => void; + padding?: number; } const Modal: FunctionComponent<IModalProps> = ({ @@ -27,6 +27,7 @@ const Modal: FunctionComponent<IModalProps> = ({ children, isOpen, onCloseModal, + padding = 4, }) => ( <ModalMui open={isOpen} @@ -35,7 +36,9 @@ const Modal: FunctionComponent<IModalProps> = ({ aria-describedby="modal-description" sx={{ zIndex: 2000 }} > - <Box sx={{ ...style, backgroundColor, boxShadow: boxShadow && 24 }}>{children}</Box> + <Box sx={{ ...style, p: padding, backgroundColor, boxShadow: boxShadow ? 24 : 0 }}> + {children} + </Box> </ModalMui> ); diff --git a/components/password/index.tsx b/components/password/index.tsx index c4cb1a35..5be4ad48 100644 --- a/components/password/index.tsx +++ b/components/password/index.tsx @@ -1,31 +1,12 @@ import React, { FunctionComponent, MouseEvent, useState } from 'react'; import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'; -import { IconButton, InputBase, InputProps, styled } from '@mui/material'; +import { IconButton, InputProps } from '@mui/material'; import TextField from '../textField'; import { InputAdornmentContainer } from './password.styled'; -export const Input = styled(InputBase)(({ theme }) => ({ - '& .MuiInputBase-input': { - 'borderRadius': 4, - 'border': '1px solid #ced4da', - 'fontSize': 14, - 'height': 18, - 'lineHeight': 20, - 'letterSpacing': 0.25, - 'padding': '8px 40px 8px 12px', - 'position': 'relative', - '&:focus': { - border: `1px solid ${theme.palette.primary.main}`, - }, - }, - '& .MuiInputBase-adornedEnd': { - 'margin-bottom': '10px', - }, -})); - export interface PasswordProps extends InputProps { label: string; helperText?: string; diff --git a/components/service/index.tsx b/components/service/index.tsx index 29b3ed70..6a906799 100644 --- a/components/service/index.tsx +++ b/components/service/index.tsx @@ -120,6 +120,12 @@ const Service: FunctionComponent<ServiceProps> = ({ <Header> <Image src={serviceLogo} alt={name} width="24" /> <Title variant="subtitle2">{name}</Title> + {/* <NextImage + src={`https://argocd.mgmt-20.kubefirst.com/api/badge?name=${name.toLowerCase()}`} + width={120} + height={20} + alt={name} + /> */} </Header> <Description variant="body2">{description}</Description> {links && !children ? linksComponent : children} diff --git a/components/service/service.styled.ts b/components/service/service.styled.ts index a1ad2ba1..489dcf22 100644 --- a/components/service/service.styled.ts +++ b/components/service/service.styled.ts @@ -8,7 +8,7 @@ import { PASTEL_LIGHT_BLUE } from '../../constants/colors'; export const AppConnector = styled.div` height: 16px; - background-color: #e2e8f0; + background-color: ${({ theme }) => theme.colors.pastelLightBlue}; top: 8px; left: 3px; position: absolute; diff --git a/components/tab/index.tsx b/components/tab/index.tsx index 663eac15..bc76988a 100644 --- a/components/tab/index.tsx +++ b/components/tab/index.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import { styled, Tab as MuiTab, tabClasses } from '@mui/material'; import { ECHO_BLUE } from '../../constants/colors'; @@ -19,7 +19,7 @@ export const a11yProps = (index: number) => { }; }; -export const Tab = styled((props: { color?: string; label: string }) => ( +export const Tab = styled((props: { color?: string; label: string | ReactNode }) => ( <MuiTab disableRipple {...props} /> ))(({ theme, color }) => ({ ...theme.typography.labelMedium, diff --git a/components/textField/index.tsx b/components/textField/index.tsx index 14a64493..31ef3ed8 100644 --- a/components/textField/index.tsx +++ b/components/textField/index.tsx @@ -11,7 +11,7 @@ export interface TextFieldProps extends InputProps { helperText?: string; } -export const Input = styled(InputBase)(({ theme, error }) => ({ +export const Input = styled(InputBase)(({ theme, error, type, endAdornment }) => ({ '& .MuiInputBase-input': { 'borderRadius': 4, 'border': `1px solid ${error ? theme.palette.error.main : '#ced4da'}`, @@ -19,7 +19,7 @@ export const Input = styled(InputBase)(({ theme, error }) => ({ 'height': 18, 'lineHeight': 20, 'letterSpacing': 0.25, - 'padding': '8px 12px', + 'padding': type === 'password' || !!endAdornment ? '8px 40px 8px 12px' : '8px 12px', 'width': '100%', '&:focus': { border: `1px solid ${error ? theme.palette.error.main : theme.palette.primary.main}`, diff --git a/containers/clusterManagement/index.tsx b/containers/clusterManagement/index.tsx index d18223d0..c8777228 100644 --- a/containers/clusterManagement/index.tsx +++ b/containers/clusterManagement/index.tsx @@ -8,7 +8,7 @@ import Typography from '../../components/typography'; import Table from '../../components/table'; import { DELETE_OPTION, VIEW_DETAILS_OPTION } from '../../constants/cluster'; import { useAppDispatch, useAppSelector } from '../../redux/store'; -import { deleteCluster, getCluster, getClusters } from '../../redux/thunks/cluster.thunk'; +import { deleteCluster, getCluster, getClusters } from '../../redux/thunks/api.thunk'; import { resetInstallState } from '../../redux/slices/installation.slice'; import { setConfigValues } from '../../redux/slices/config.slice'; import { Cluster, ClusterRequestProps } from '../../types/provision'; @@ -43,7 +43,7 @@ const ClusterManagement: FunctionComponent<ClusterManagementProps> = ({ apiUrl, const { push } = useRouter(); const dispatch = useAppDispatch(); - const { isDeleted, isDeleting, isError, clusters } = useAppSelector(({ cluster }) => cluster); + const { isDeleted, isDeleting, isError, clusters } = useAppSelector(({ api }) => api); const handleMenuClick = (option: string, rowItem: Row) => { const { clusterName } = rowItem; diff --git a/containers/conciseLogs/index.tsx b/containers/conciseLogs/index.tsx index 623f8c79..3641cbe2 100644 --- a/containers/conciseLogs/index.tsx +++ b/containers/conciseLogs/index.tsx @@ -28,11 +28,11 @@ export interface ConciseLogsProps { const ConciseLogs: FunctionComponent<ConciseLogsProps> = ({ completedSteps }) => { const { installType, isError, isProvisioned, lastErrorCondition } = useAppSelector( - ({ cluster, installation }) => ({ - cluster: cluster.selectedCluster, - isProvisioned: cluster.isProvisioned, - lastErrorCondition: cluster.lastErrorCondition, - isError: cluster.isError, + ({ api, installation }) => ({ + cluster: api.selectedCluster, + isProvisioned: api.isProvisioned, + lastErrorCondition: api.lastErrorCondition, + isError: api.isError, installType: installation.installType, }), ); diff --git a/containers/marketplace/index.tsx b/containers/marketplace/index.tsx new file mode 100644 index 00000000..24901f6a --- /dev/null +++ b/containers/marketplace/index.tsx @@ -0,0 +1,128 @@ +import React, { FunctionComponent, useMemo, useState } from 'react'; +import { FormControlLabel, FormGroup } from '@mui/material'; +import intersection from 'lodash/intersection'; +import NextLink from 'next/link'; + +import Checkbox from '../../components/checkbox'; +import Typography from '../../components/typography'; +import MarketplaceCard from '../../components/marketplaceCard'; +import MarketplaceModal from '../../components/marketplaceModal'; +import useModal from '../../hooks/useModal'; +import { useAppSelector } from '../../redux/store'; +import { MarketplaceApp } from '../../types/marketplace'; +import { VOLCANIC_SAND } from '../../constants/colors'; + +import { CardsContainer, Container, Content, Filter } from './marketplace.styled'; + +const STATIC_HELP_CARD: MarketplaceApp = { + categories: [], + name: 'Can’t find what you need?', + image_url: + 'https://raw.githubusercontent.com/kubefirst/kubefirst/main/images/kubefirst-light.svg', +}; + +const Marketplace: FunctionComponent = () => { + const [selectedCategories, setSelectedCategories] = useState<Array<string>>([]); + const [selectedApp, setSelectedApp] = useState<MarketplaceApp>(); + + const { isOpen, openModal, closeModal } = useModal(); + + const marketplaceApps = useAppSelector(({ api }) => api.marketplaceApps); + const categories = useMemo( + () => + marketplaceApps + .map(({ categories }) => categories) + .reduce((previous, current) => { + const values = current.filter((category) => !previous.includes(category)); + return [...previous, ...values]; + }, []), + [marketplaceApps], + ); + + const onClickCategory = (category: string) => { + const isCategorySelected = selectedCategories.includes(category); + + if (isCategorySelected) { + setSelectedCategories( + selectedCategories.filter((selectedCategory) => selectedCategory !== category), + ); + } else { + setSelectedCategories([...selectedCategories, category]); + } + }; + + const handleSelectedApp = (app: MarketplaceApp) => { + setSelectedApp(app); + openModal(); + }; + + const filteredApps = useMemo(() => { + if (!selectedCategories.length) { + return marketplaceApps; + } + + return ( + marketplaceApps && + marketplaceApps.filter( + ({ categories }) => intersection(categories, selectedCategories).length > 0, + ) + ); + }, [marketplaceApps, selectedCategories]); + + return ( + <Container> + <Filter> + <Typography variant="subtitle2" sx={{ mb: 3 }}> + Category + </Typography> + {categories && + categories.map((category) => ( + <FormGroup key={category} sx={{ mb: 2 }}> + <FormControlLabel + control={<Checkbox sx={{ mr: 2 }} onClick={() => onClickCategory(category)} />} + label={ + <Typography variant="body2" color={VOLCANIC_SAND}> + {category} + </Typography> + } + sx={{ ml: 0 }} + /> + </FormGroup> + ))} + </Filter> + <Content> + <Typography variant="subtitle2">Featured</Typography> + <CardsContainer> + {filteredApps.map((app) => ( + <MarketplaceCard key={app.name} {...app} onClick={() => handleSelectedApp(app)} /> + ))} + <MarketplaceCard {...STATIC_HELP_CARD} showSubmitButton={false}> + <> + To suggest an open source app that installs to your cluster, discuss your idea via an{' '} + <NextLink href="https://github.com/kubefirst/kubefirst/issues" target="_blank"> + issue + </NextLink> + . Learn how you can do this on our{' '} + <NextLink href="https://github.com/kubefirst/marketplace" target="_blank"> + Contributing file + </NextLink> + . + <br /> + <br /> + Alternatively contact us via our{' '} + <NextLink href="https://kubefirst.io/slack" target="_blank"> + Slack Community + </NextLink>{' '} + in the #helping-hands or #contributors channels. + </> + </MarketplaceCard> + </CardsContainer> + </Content> + {isOpen && selectedApp?.name && ( + <MarketplaceModal closeModal={closeModal} isOpen={isOpen} {...selectedApp} /> + )} + </Container> + ); +}; + +export default Marketplace; diff --git a/containers/marketplace/marketplace.styled.ts b/containers/marketplace/marketplace.styled.ts new file mode 100644 index 00000000..d4938252 --- /dev/null +++ b/containers/marketplace/marketplace.styled.ts @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +import Typography from '../../components/typography'; + +export const CardsContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 24px; +`; + +export const Container = styled.div` + display: flex; + height: calc(100% - 30px); + width: 100%; +`; + +export const Content = styled.div` + padding: 24px; + width: 100%; +`; + +export const Filter = styled.div` + background: ${({ theme }) => theme.colors.white}; + border-width: 1px 1px 0px 1px; + border-style: solid; + border-color: ${({ theme }) => theme.colors.pastelLightBlue}; + border-radius: 8px; + padding: 24px; + width: 266px; +`; diff --git a/containers/provision/index.tsx b/containers/provision/index.tsx index a6aee970..e2d0db4a 100644 --- a/containers/provision/index.tsx +++ b/containers/provision/index.tsx @@ -1,7 +1,7 @@ import React, { FunctionComponent, useCallback, useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; -import { createCluster } from '../../redux/thunks/cluster.thunk'; +import { createCluster } from '../../redux/thunks/api.thunk'; import InstallationStepContainer from '../../components/installationStepContainer'; import InstallationInfoCard from '../../components/installationInfoCard'; import { InstallationsSelection } from '../installationsSelection'; @@ -16,7 +16,7 @@ import { useInstallation } from '../../hooks/useInstallation'; import { InstallValues, InstallationType } from '../../types/redux'; import { GitProvider } from '../../types'; import { setConfigValues } from '../../redux/slices/config.slice'; -import { clearClusterState } from '../../redux/slices/cluster.slice'; +import { clearClusterState } from '../../redux/slices/api.slice'; import AdvancedOptions from '../clusterForms/shared/advancedOptions'; import ErrorBanner from '../../components/errorBanner'; import Button from '../../components/button'; @@ -34,7 +34,7 @@ const Provision: FunctionComponent<ProvisionProps> = ({ apiUrl, useTelemetry }) ({ installation }) => installation, ); - const { isProvisioned } = useAppSelector(({ cluster }) => cluster); + const { isProvisioned } = useAppSelector(({ api }) => api); const { stepTitles, diff --git a/containers/services/index.tsx b/containers/services/index.tsx index 91b53610..cef22cf3 100644 --- a/containers/services/index.tsx +++ b/containers/services/index.tsx @@ -1,16 +1,27 @@ -import React, { FunctionComponent, useEffect, useMemo, useCallback } from 'react'; +import React, { FunctionComponent, useEffect, useMemo, useCallback, useState } from 'react'; +import { Box, Tabs } from '@mui/material'; -import { DOCS_LINK } from '../../constants'; -import { setConfigValues } from '../../redux/slices/config.slice'; -import { GitProvider } from '../../types'; -import { useAppDispatch, useAppSelector } from '../../redux/store'; import Service from '../service'; +import Marketplace from '../marketplace'; +import TabPanel, { Tab, a11yProps } from '../../components/tab'; import Typography from '../../components/typography'; import { useTelemetryMutation } from '../../redux/api'; +import { setConfigValues } from '../../redux/slices/config.slice'; +import { getMarketplaceApps } from '../../redux/thunks/api.thunk'; +import { useAppDispatch, useAppSelector } from '../../redux/store'; +import { GitProvider } from '../../types'; +import { DOCS_LINK } from '../../constants'; +import { BISCAY, SALTBOX_BLUE, VOLCANIC_SAND } from '../../constants/colors'; import { Container, Header, LearnMoreLink, ServicesContainer } from './services.styled'; +enum SERVICES_TABS { + PROVISIONED = 0, + MARKETPLACE = 1, +} + export interface ServicesProps { + apiUrl: string; argoUrl: string; argoWorkflowsUrl: string; atlantisUrl: string; @@ -30,6 +41,7 @@ export interface ServicesProps { } const Services: FunctionComponent<ServicesProps> = ({ + apiUrl, argoUrl, argoWorkflowsUrl, atlantisUrl, @@ -43,16 +55,13 @@ const Services: FunctionComponent<ServicesProps> = ({ vaultUrl, metaphor, }) => { + const [activeTab, setActiveTab] = useState<number>(0); const [sendTelemetryEvent] = useTelemetryMutation(); const isTelemetryEnabled = useAppSelector(({ config }) => config.isTelemetryEnabled); const dispatch = useAppDispatch(); - useEffect(() => { - dispatch(setConfigValues({ isTelemetryEnabled: useTelemetry, kubefirstVersion, k3dDomain })); - }, [dispatch, useTelemetry, kubefirstVersion, k3dDomain]); - const gitTileProvider = useMemo( () => (gitProvider === GitProvider.GITHUB ? 'GitHub' : 'GitLab'), [gitProvider], @@ -123,11 +132,40 @@ const Services: FunctionComponent<ServicesProps> = ({ [isTelemetryEnabled, sendTelemetryEvent], ); + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + useEffect(() => { + dispatch( + setConfigValues({ isTelemetryEnabled: useTelemetry, kubefirstVersion, k3dDomain, apiUrl }), + ); + dispatch(getMarketplaceApps()); + }, [dispatch, useTelemetry, kubefirstVersion, k3dDomain, apiUrl]); + return ( <Container> <Header> <Typography variant="h6">Services Overview</Typography> - <Typography variant="body2"> + </Header> + <Box sx={{ width: 'fit-content', mb: 4 }}> + <Tabs value={activeTab} onChange={handleChange} indicatorColor="primary"> + <Tab + color={activeTab === SERVICES_TABS.PROVISIONED ? BISCAY : SALTBOX_BLUE} + label={<Typography variant="buttonSmall">Provisioned services</Typography>} + {...a11yProps(SERVICES_TABS.PROVISIONED)} + sx={{ textTransform: 'capitalize', mr: 3 }} + /> + <Tab + color={activeTab === SERVICES_TABS.MARKETPLACE ? BISCAY : SALTBOX_BLUE} + label={<Typography variant="buttonSmall">Marketplace</Typography>} + {...a11yProps(SERVICES_TABS.MARKETPLACE)} + sx={{ textTransform: 'capitalize' }} + /> + </Tabs> + </Box> + <TabPanel value={activeTab} index={SERVICES_TABS.PROVISIONED}> + <Typography variant="body2" sx={{ mb: 3 }} color={VOLCANIC_SAND}> Click on a link to access the service Kubefirst has provisioned for you.{' '} <LearnMoreLink href={DOCS_LINK} @@ -137,18 +175,31 @@ const Services: FunctionComponent<ServicesProps> = ({ Learn more </LearnMoreLink> </Typography> - </Header> - <ServicesContainer> - {services.map(({ name, ...rest }) => ( - <Service - key={name} - name={name} - {...rest} - onClickLink={onClickLink} - domainName={domainName} - /> - ))} - </ServicesContainer> + <ServicesContainer> + {services.map(({ name, ...rest }) => ( + <Service + key={name} + name={name} + {...rest} + onClickLink={onClickLink} + domainName={domainName} + /> + ))} + </ServicesContainer> + </TabPanel> + <TabPanel value={activeTab} index={SERVICES_TABS.MARKETPLACE}> + <Typography variant="body2" sx={{ mb: 3 }} color={VOLCANIC_SAND}> + Click on a link to access the service Kubefirst has provisioned for you.{' '} + <LearnMoreLink + href={DOCS_LINK} + target="_blank" + onClick={() => onClickLink(DOCS_LINK, 'docs')} + > + Learn more + </LearnMoreLink> + </Typography> + <Marketplace /> + </TabPanel> </Container> ); }; diff --git a/containers/services/services.styled.ts b/containers/services/services.styled.ts index 6247ad2f..c7edd832 100644 --- a/containers/services/services.styled.ts +++ b/containers/services/services.styled.ts @@ -3,10 +3,9 @@ import styled from 'styled-components'; export const Container = styled.div` height: calc(100vh - 80px); - overflow: auto; margin: 0 auto; padding: 40px; - max-width: 1192px; + width: 1192px; `; export const Header = styled.div` @@ -14,7 +13,7 @@ export const Header = styled.div` display: flex; flex-direction: column; gap: 8px; - margin-bottom: 40px; + margin-bottom: 24px; `; export const LearnMoreLink = styled(Link)` diff --git a/containers/terminalLogs/terminalLogs.tsx b/containers/terminalLogs/terminalLogs.tsx index 8f3a8143..06af96ed 100644 --- a/containers/terminalLogs/terminalLogs.tsx +++ b/containers/terminalLogs/terminalLogs.tsx @@ -21,10 +21,10 @@ import ConciseLogs from '../conciseLogs'; import useModal from '../../hooks/useModal'; import Modal from '../../components/modal'; import { useAppDispatch, useAppSelector } from '../../redux/store'; -import { getCluster } from '../../redux/thunks/cluster.thunk'; +import { getCluster } from '../../redux/thunks/api.thunk'; import { ClusterRequestProps } from '../../types/provision'; import { clearError, setError } from '../../redux/slices/installation.slice'; -import { setCompletedSteps } from '../../redux/slices/cluster.slice'; +import { setCompletedSteps } from '../../redux/slices/api.slice'; import TabPanel, { Tab, a11yProps } from '../../components/tab'; import FlappyKray from '../../components/flappyKray'; import { CLUSTER_CHECKS } from '../../constants/cluster'; @@ -49,7 +49,7 @@ const TerminalLogs: FunctionComponent = () => { const dispatch = useAppDispatch(); const { config: { apiUrl = '' }, - cluster: { + api: { isProvisioned, isProvisioning, isError, @@ -58,10 +58,10 @@ const TerminalLogs: FunctionComponent = () => { completedSteps, }, installation: { values }, - } = useAppSelector(({ config, cluster, installation }) => ({ + } = useAppSelector(({ config, api, installation }) => ({ installation, config, - cluster, + api, })); const { isOpen, openModal, closeModal } = useModal(); diff --git a/declaration.d.ts b/declaration.d.ts index 0f1f22b2..6418b10b 100644 --- a/declaration.d.ts +++ b/declaration.d.ts @@ -61,6 +61,7 @@ declare module 'styled-components' { magnolia: string; royanPurple: string; sefidWhite: string; + pastelLightBlue: string; // Kubefirst color palette americanBlue: string; diff --git a/next.config.js b/next.config.js index bd8eecea..9a5b36db 100644 --- a/next.config.js +++ b/next.config.js @@ -17,6 +17,12 @@ const nextConfig = { contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", remotePatterns: [], unoptimized: false, + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], }, compiler: { styledComponents: true, diff --git a/pages/services.tsx b/pages/services.tsx index 1237084c..dc961161 100644 --- a/pages/services.tsx +++ b/pages/services.tsx @@ -5,6 +5,7 @@ import useFeatureFlag from '../hooks/useFeatureFlag'; import Services from '../containers/services'; interface ServicesPageProps { + apiUrl: string; argoUrl: string; argoWorkflowsUrl: string; atlantisUrl: string; @@ -39,6 +40,7 @@ const ServicesPage: FunctionComponent<ServicesPageProps> = (props) => { export async function getServerSideProps() { const { + API_URL = '', ARGO_CD_URL = '', ARGO_WORKFLOWS_URL = '', ATLANTIS_URL = '', @@ -57,6 +59,7 @@ export async function getServerSideProps() { return { props: { + apiUrl: API_URL, argoUrl: ARGO_CD_URL, argoWorkflowsUrl: ARGO_WORKFLOWS_URL, atlantisUrl: ATLANTIS_URL, diff --git a/redux/api/index.ts b/redux/api/index.ts index 9aa5b2c2..307d6c49 100644 --- a/redux/api/index.ts +++ b/redux/api/index.ts @@ -6,6 +6,7 @@ import { TelemetryResponseData } from '../../pages/api/telemetry'; import { SendTelemetryArgs } from '../../services/telemetry'; export const consoleApi = createApi({ + reducerPath: 'internalApi', baseQuery: fetchBaseQuery({ baseUrl: '', }), diff --git a/redux/slices/cluster.slice.ts b/redux/slices/api.slice.ts similarity index 82% rename from redux/slices/cluster.slice.ts rename to redux/slices/api.slice.ts index a287a807..89e5bbd6 100644 --- a/redux/slices/cluster.slice.ts +++ b/redux/slices/api.slice.ts @@ -1,7 +1,14 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import { createCluster, deleteCluster, getCluster, getClusters } from '../thunks/cluster.thunk'; +import { + createCluster, + deleteCluster, + getCluster, + getClusters, + getMarketplaceApps, +} from '../thunks/api.thunk'; import { Cluster, ClusterStatus } from '../../types/provision'; +import { MarketplaceApp } from '../../types/marketplace'; export interface ApiState { loading: boolean; @@ -15,6 +22,7 @@ export interface ApiState { clusters: Array<Cluster>; selectedCluster?: Cluster; completedSteps: Array<{ label: string; order: number }>; + marketplaceApps: Array<MarketplaceApp>; } export const initialState: ApiState = { @@ -29,10 +37,11 @@ export const initialState: ApiState = { clusters: [], selectedCluster: undefined, completedSteps: [], + marketplaceApps: [], }; -const clusterSlice = createSlice({ - name: 'cluster', +const apiSlice = createSlice({ + name: 'api', initialState, reducers: { setCompletedSteps: (state, action) => { @@ -96,10 +105,16 @@ const clusterSlice = createSlice({ state.loading = false; state.isError = false; state.clusters = payload; - }); + }) + .addCase( + getMarketplaceApps.fulfilled, + (state, { payload }: PayloadAction<Array<MarketplaceApp>>) => { + state.marketplaceApps = payload; + }, + ); }, }); -export const { setCompletedSteps, clearClusterState } = clusterSlice.actions; +export const { setCompletedSteps, clearClusterState } = apiSlice.actions; -export const clusterReducer = clusterSlice.reducer; +export const apiReducer = apiSlice.reducer; diff --git a/redux/slices/index.ts b/redux/slices/index.ts index 81e1cfdf..bf568181 100644 --- a/redux/slices/index.ts +++ b/redux/slices/index.ts @@ -2,5 +2,5 @@ export { readinessReducer } from './readiness.slice'; export { gitReducer } from './git.slice'; export { configReducer } from './config.slice'; export { installationReducer } from './installation.slice'; -export { clusterReducer } from './cluster.slice'; +export { apiReducer } from './api.slice'; export { featureFlagsReducer } from './featureFlags.slice'; diff --git a/redux/store.ts b/redux/store.ts index a3205f00..2a485a4c 100644 --- a/redux/store.ts +++ b/redux/store.ts @@ -5,7 +5,7 @@ import { createWrapper } from 'next-redux-wrapper'; import { consoleApi } from './api'; import { configReducer, - clusterReducer, + apiReducer, featureFlagsReducer, gitReducer, installationReducer, @@ -20,7 +20,7 @@ export const makeStore = () => installation: installationReducer, git: gitReducer, readiness: readinessReducer, - cluster: clusterReducer, + api: apiReducer, featureFlags: featureFlagsReducer, }, middleware: (gDM) => gDM().concat(consoleApi.middleware), diff --git a/redux/thunks/cluster.thunk.ts b/redux/thunks/api.thunk.ts similarity index 81% rename from redux/thunks/cluster.thunk.ts rename to redux/thunks/api.thunk.ts index adb6c65a..ed200752 100644 --- a/redux/thunks/cluster.thunk.ts +++ b/redux/thunks/api.thunk.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { createAsyncThunk } from '@reduxjs/toolkit'; +import { MarketplaceApp } from 'types/marketplace'; import { AppDispatch, RootState } from '../store'; import { Cluster, ClusterRequestProps, ClusterResponse } from '../../types/provision'; @@ -47,7 +48,7 @@ export const createCluster = createAsyncThunk< dispatch: AppDispatch; state: RootState; } ->('cluster/provisioning', async ({ apiUrl }, { getState }) => { +>('api/cluster/provisioning', async ({ apiUrl }, { getState }) => { const { installation: { installType, gitProvider, values }, } = getState(); @@ -75,7 +76,10 @@ export const createCluster = createAsyncThunk< ...values?.vultr_auth, }, }; - const res = await axios.post(`${apiUrl}/cluster/${values?.clusterName || 'kubefirst'}`, params); + const res = await axios.post( + `${apiUrl}/api/cluster/${values?.clusterName || 'kubefirst'}`, + params, + ); if ('error' in res) { throw res.error; @@ -90,7 +94,7 @@ export const getCluster = createAsyncThunk< dispatch: AppDispatch; state: RootState; } ->('cluster/get', async ({ apiUrl, clusterName }) => { +>('api/cluster/get', async ({ apiUrl, clusterName }) => { const res = await axios.get(`${apiUrl}/cluster/${clusterName || 'kubefirst'}`); if ('error' in res) { @@ -106,7 +110,7 @@ export const getClusters = createAsyncThunk< dispatch: AppDispatch; state: RootState; } ->('cluster/getClusters', async ({ apiUrl }) => { +>('api/cluster/getClusters', async ({ apiUrl }) => { const res = await axios.get(`${apiUrl}/cluster`); if ('error' in res) { @@ -122,7 +126,7 @@ export const deleteCluster = createAsyncThunk< dispatch: AppDispatch; state: RootState; } ->('cluster/delete', async ({ apiUrl, clusterName }) => { +>('api/cluster/delete', async ({ apiUrl, clusterName }) => { const res = await axios.delete(`${apiUrl}/cluster/${clusterName || 'kubefirst'}`); if ('error' in res) { @@ -130,3 +134,23 @@ export const deleteCluster = createAsyncThunk< } return res.data; }); + +export const getMarketplaceApps = createAsyncThunk< + Array<MarketplaceApp>, + void, + { + dispatch: AppDispatch; + state: RootState; + } +>('api/getMarketplaceApps', async (_, { getState }) => { + const { + config: { apiUrl }, + } = getState(); + + const res = await axios.get(`${apiUrl}/marketplace/apps`); + + if ('error' in res) { + throw res.error; + } + return res.data.apps; +}); diff --git a/theme/index.ts b/theme/index.ts index 27388183..e40b65dd 100644 --- a/theme/index.ts +++ b/theme/index.ts @@ -37,6 +37,7 @@ import { MAGNOLIA, ROYAL_PURPLE, SEFID_WHITE, + PASTEL_LIGHT_BLUE, } from '../constants/colors'; export const theme: DefaultTheme = { @@ -77,5 +78,6 @@ export const theme: DefaultTheme = { magnolia: MAGNOLIA, royanPurple: ROYAL_PURPLE, sefidWhite: SEFID_WHITE, + pastelLightBlue: PASTEL_LIGHT_BLUE, }, }; diff --git a/types/marketplace/index.ts b/types/marketplace/index.ts new file mode 100644 index 00000000..1f2b01d3 --- /dev/null +++ b/types/marketplace/index.ts @@ -0,0 +1,7 @@ +export interface MarketplaceApp { + name: string; + secret_keys?: Array<string>; + image_url: string; + description?: string; + categories: Array<string>; +} From 2930fc53ffd7cb0b9b864069ac885b87f0f97f0c Mon Sep 17 00:00:00 2001 From: CristhianF7 <CristhianF7@gmail.com> Date: Thu, 18 May 2023 22:51:40 -0500 Subject: [PATCH 2/6] feat: marketplace + api integration --- components/menu/index.tsx | 2 +- components/service/index.tsx | 22 +------ containers/clusterManagement/index.tsx | 12 ++-- containers/header/header.styled.ts | 14 ++++ containers/header/index.tsx | 58 ++++++++++++++++ containers/marketplace/index.tsx | 20 ++++-- containers/service/index.tsx | 1 + containers/services/index.tsx | 91 ++------------------------ pages/_app.tsx | 9 +-- pages/services.tsx | 20 ------ redux/slices/api.slice.ts | 19 +----- redux/slices/cluster.slice.ts | 57 ++++++++++++++++ redux/slices/index.ts | 1 + redux/store.ts | 4 +- redux/thunks/api.thunk.ts | 55 +++++++++++++--- types/marketplace/index.ts | 5 ++ types/provision/index.ts | 9 +++ 17 files changed, 232 insertions(+), 167 deletions(-) create mode 100644 containers/header/header.styled.ts create mode 100644 containers/header/index.tsx create mode 100644 redux/slices/cluster.slice.ts diff --git a/components/menu/index.tsx b/components/menu/index.tsx index 9ae4dd36..f3ded3c9 100644 --- a/components/menu/index.tsx +++ b/components/menu/index.tsx @@ -8,7 +8,7 @@ import Typography from '../typography'; import { VOLCANIC_SAND } from '../../constants/colors'; export interface MenuProps { - isDisabled: boolean; + isDisabled?: boolean; label: string | ReactNode; options?: Array<{ label: string; diff --git a/components/service/index.tsx b/components/service/index.tsx index 6a906799..cdf34ef1 100644 --- a/components/service/index.tsx +++ b/components/service/index.tsx @@ -1,13 +1,6 @@ import React, { FunctionComponent, useCallback, useMemo } from 'react'; -import { StaticImageData } from 'next/image'; import { Box, CircularProgress } from '@mui/material'; -import ArgoCDLogo from '../../assets/argocd.svg'; -import GitLabLogo from '../../assets/gitlab.svg'; -import GitHubLogo from '../../assets/github.svg'; -import VaultLogo from '../../assets/vault.svg'; -import AtlantisLogo from '../../assets/atlantis.svg'; -import MetaphorLogo from '../../assets/metaphor.svg'; import Typography from '../typography'; import { formatDomain } from '../../utils/url/formatDomain'; import Tooltip from '../tooltip'; @@ -25,20 +18,11 @@ import { Title, } from './service.styled'; -const CARD_IMAGES: { [key: string]: StaticImageData } = { - ['Argo CD']: ArgoCDLogo, - ['Argo Workflows']: ArgoCDLogo, - ['GitLab']: GitLabLogo, - ['GitHub']: GitHubLogo, - ['Vault']: VaultLogo, - ['Atlantis']: AtlantisLogo, - ['Metaphor']: MetaphorLogo, -}; - export interface ServiceProps { description?: string; domainName: string; children?: React.ReactNode; + image: string; name: string; links?: { [url: string]: boolean }; onClickLink: (link: string, name: string) => void; @@ -48,11 +32,11 @@ const Service: FunctionComponent<ServiceProps> = ({ description, domainName, children, + image, name, links, onClickLink, }) => { - const serviceLogo = useMemo(() => CARD_IMAGES[name], [name]); const isMetaphor = useMemo(() => name === 'Metaphor', [name]); const serviceLink = useCallback( @@ -118,7 +102,7 @@ const Service: FunctionComponent<ServiceProps> = ({ return ( <Container> <Header> - <Image src={serviceLogo} alt={name} width="24" /> + <Image src={image} alt={name} width="24" height="24" /> <Title variant="subtitle2">{name}</Title> {/* <NextImage src={`https://argocd.mgmt-20.kubefirst.com/api/badge?name=${name.toLowerCase()}`} diff --git a/containers/clusterManagement/index.tsx b/containers/clusterManagement/index.tsx index c8777228..43e6414f 100644 --- a/containers/clusterManagement/index.tsx +++ b/containers/clusterManagement/index.tsx @@ -124,11 +124,13 @@ const ClusterManagement: FunctionComponent<ClusterManagementProps> = ({ apiUrl, </Button> </Header> <Content> - <Table - columns={getClusterManagementColumns(handleMenuClick)} - rows={clusters} - getRowClassName={getClusterState} - /> + {clusters && ( + <Table + columns={getClusterManagementColumns(handleMenuClick)} + rows={clusters} + getRowClassName={getClusterState} + /> + )} </Content> <Snackbar anchorOrigin={{ diff --git a/containers/header/header.styled.ts b/containers/header/header.styled.ts new file mode 100644 index 00000000..59ea94c7 --- /dev/null +++ b/containers/header/header.styled.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +import row from '../../components/row'; + +export const Container = styled(row)` + background-color: ${({ theme }) => theme.colors.white}; + box-shadow: 0px 2px 4px rgba(31, 41, 55, 0.06); + height: 46px; + width: 100%; + z-index: 1500; +`; + +export const ClusterIndicator = styled.div``; +export const ClusterMenu = styled.div``; diff --git a/containers/header/index.tsx b/containers/header/index.tsx new file mode 100644 index 00000000..7e6c34d0 --- /dev/null +++ b/containers/header/index.tsx @@ -0,0 +1,58 @@ +import React, { FunctionComponent, useEffect } from 'react'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import { setSelectedCluster } from 'redux/slices/cluster.slice'; + +import { getClusters } from '../../redux/thunks/api.thunk'; +import Menu from '../../components/menu'; +import { useAppDispatch, useAppSelector } from '../../redux/store'; + +import { ClusterIndicator, ClusterMenu, Container } from './header.styled'; + +const Header: FunctionComponent = () => { + const dispatch = useAppDispatch(); + const { apiUrl, clusters, selectedCluster } = useAppSelector(({ api, cluster, config }) => ({ + clusters: api.clusters, + apiUrl: config.apiUrl, + selectedCluster: cluster.selectedCluster, + })); + + const handleSelectCluster = (selectedClusterName: string) => { + const selectedCluster = clusters.find(({ clusterName }) => clusterName === selectedClusterName); + + if (selectedCluster) { + dispatch(setSelectedCluster(selectedCluster)); + } + }; + + useEffect(() => { + if (apiUrl) { + dispatch(getClusters({ apiUrl })); + } + }, [apiUrl, dispatch]); + + useEffect(() => { + if (clusters.length && !selectedCluster) { + dispatch(setSelectedCluster(clusters[0])); + } + }, [clusters, clusters.length, dispatch, selectedCluster]); + + return ( + <Container> + {clusters?.length ? ( + <Menu + onClickMenu={(cluster) => handleSelectCluster(cluster)} + label={ + <ClusterMenu> + <ClusterIndicator /> + {selectedCluster?.clusterName} + <KeyboardArrowDownIcon /> + </ClusterMenu> + } + options={clusters && clusters.map(({ clusterName }) => ({ label: clusterName }))} + /> + ) : null} + </Container> + ); +}; + +export default Header; diff --git a/containers/marketplace/index.tsx b/containers/marketplace/index.tsx index 24901f6a..1492fb2f 100644 --- a/containers/marketplace/index.tsx +++ b/containers/marketplace/index.tsx @@ -17,8 +17,7 @@ import { CardsContainer, Container, Content, Filter } from './marketplace.styled const STATIC_HELP_CARD: MarketplaceApp = { categories: [], name: 'Can’t find what you need?', - image_url: - 'https://raw.githubusercontent.com/kubefirst/kubefirst/main/images/kubefirst-light.svg', + image_url: 'https://assets.kubefirst.com/console/help.png', }; const Marketplace: FunctionComponent = () => { @@ -27,7 +26,7 @@ const Marketplace: FunctionComponent = () => { const { isOpen, openModal, closeModal } = useModal(); - const marketplaceApps = useAppSelector(({ api }) => api.marketplaceApps); + const marketplaceApps = useAppSelector(({ cluster }) => cluster.marketplaceApps); const categories = useMemo( () => marketplaceApps @@ -75,6 +74,19 @@ const Marketplace: FunctionComponent = () => { <Typography variant="subtitle2" sx={{ mb: 3 }}> Category </Typography> + <FormGroup sx={{ mb: 2 }}> + <FormControlLabel + control={ + <Checkbox sx={{ mr: 2 }} onClick={() => onClickCategory('all')} defaultChecked /> + } + label={ + <Typography variant="body2" color={VOLCANIC_SAND}> + All + </Typography> + } + sx={{ ml: 0 }} + /> + </FormGroup> {categories && categories.map((category) => ( <FormGroup key={category} sx={{ mb: 2 }}> @@ -91,7 +103,7 @@ const Marketplace: FunctionComponent = () => { ))} </Filter> <Content> - <Typography variant="subtitle2">Featured</Typography> + <Typography variant="subtitle2">All</Typography> <CardsContainer> {filteredApps.map((app) => ( <MarketplaceCard key={app.name} {...app} onClick={() => handleSelectedApp(app)} /> diff --git a/containers/service/index.tsx b/containers/service/index.tsx index b5efdfef..ad600ef4 100644 --- a/containers/service/index.tsx +++ b/containers/service/index.tsx @@ -8,6 +8,7 @@ export interface ServiceProps { description?: string; domainName: string; children?: React.ReactNode; + image: string; name: string; links?: Array<string>; onClickLink: (link: string, name: string) => void; diff --git a/containers/services/index.tsx b/containers/services/index.tsx index cef22cf3..a250cca9 100644 --- a/containers/services/index.tsx +++ b/containers/services/index.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useEffect, useMemo, useCallback, useState } from 'react'; +import React, { FunctionComponent, useEffect, useCallback, useState } from 'react'; import { Box, Tabs } from '@mui/material'; import Service from '../service'; @@ -9,7 +9,6 @@ import { useTelemetryMutation } from '../../redux/api'; import { setConfigValues } from '../../redux/slices/config.slice'; import { getMarketplaceApps } from '../../redux/thunks/api.thunk'; import { useAppDispatch, useAppSelector } from '../../redux/store'; -import { GitProvider } from '../../types'; import { DOCS_LINK } from '../../constants'; import { BISCAY, SALTBOX_BLUE, VOLCANIC_SAND } from '../../constants/colors'; @@ -22,106 +21,30 @@ enum SERVICES_TABS { export interface ServicesProps { apiUrl: string; - argoUrl: string; - argoWorkflowsUrl: string; atlantisUrl: string; domainName: string; - githubOwner: string; - gitlabOwner: string; - gitProvider: string; k3dDomain: string; kubefirstVersion: string; useTelemetry: boolean; - vaultUrl: string; - metaphor: { - development: string; - staging: string; - production: string; - }; } const Services: FunctionComponent<ServicesProps> = ({ apiUrl, - argoUrl, - argoWorkflowsUrl, - atlantisUrl, domainName, - githubOwner, - gitlabOwner, - gitProvider, k3dDomain, kubefirstVersion, useTelemetry, - vaultUrl, - metaphor, }) => { const [activeTab, setActiveTab] = useState<number>(0); const [sendTelemetryEvent] = useTelemetryMutation(); - const isTelemetryEnabled = useAppSelector(({ config }) => config.isTelemetryEnabled); + const { isTelemetryEnabled, clusterServices } = useAppSelector(({ config, cluster }) => ({ + isTelemetryEnabled: config.isTelemetryEnabled, + clusterServices: cluster.clusterServices, + })); const dispatch = useAppDispatch(); - const gitTileProvider = useMemo( - () => (gitProvider === GitProvider.GITHUB ? 'GitHub' : 'GitLab'), - [gitProvider], - ); - - const gitLinks = useMemo( - () => [ - gitProvider && `https://${gitProvider}.com/${githubOwner || gitlabOwner}/gitops`, - gitProvider && `https://${gitProvider}.com/${githubOwner || gitlabOwner}/metaphor`, - ], - [gitProvider, githubOwner, gitlabOwner], - ); - - const services = useMemo( - () => [ - { - name: gitTileProvider, - description: `The ${gitTileProvider} repository contains all the Infrastructure as Code and GitOps configurations.`, - links: gitLinks, - }, - { - name: 'Vault', - description: `Kubefirst’s secrets manager and identity provider.`, - links: [vaultUrl], - }, - { - name: 'Argo CD', - description: `A GitOps oriented continuous delivery tool for managing all of our applications across our - kubernetes clusters.`, - links: [argoUrl], - }, - { - name: 'Argo Workflows', - description: `The workflow engine for orchestrating parallel jobs on Kubernetes.`, - links: [`${argoWorkflowsUrl}/workflows`], - }, - { - name: 'Atlantis', - description: `Kubefirst manages terraform workflows with atlantis automation.`, - links: [atlantisUrl], - }, - { - name: 'Metaphor', - description: `A multi-environment demonstration space for frontend application best practices that’s easy to apply to other projects.`, - links: [metaphor?.development, metaphor?.staging, metaphor?.production], - }, - ], - [ - argoUrl, - argoWorkflowsUrl, - atlantisUrl, - gitLinks, - gitTileProvider, - metaphor?.development, - metaphor?.production, - metaphor?.staging, - vaultUrl, - ], - ); - const onClickLink = useCallback( (url: string, name: string) => { if (isTelemetryEnabled) { @@ -176,7 +99,7 @@ const Services: FunctionComponent<ServicesProps> = ({ </LearnMoreLink> </Typography> <ServicesContainer> - {services.map(({ name, ...rest }) => ( + {clusterServices.map(({ name, ...rest }) => ( <Service key={name} name={name} @@ -189,7 +112,7 @@ const Services: FunctionComponent<ServicesProps> = ({ </TabPanel> <TabPanel value={activeTab} index={SERVICES_TABS.MARKETPLACE}> <Typography variant="body2" sx={{ mb: 3 }} color={VOLCANIC_SAND}> - Click on a link to access the service Kubefirst has provisioned for you.{' '} + Add your favourite applications to your cluster.{' '} <LearnMoreLink href={DOCS_LINK} target="_blank" diff --git a/pages/_app.tsx b/pages/_app.tsx index 389976b3..982fec3d 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -8,6 +8,7 @@ import styled, { ThemeProvider } from 'styled-components'; import { muiTheme } from '../theme/muiTheme'; import { theme } from '../theme'; import { wrapper } from '../redux/store'; +import Header from '../containers/header'; import Navigation from '../components/navigation'; import Row from '../components/row'; import Column from '../components/column'; @@ -20,14 +21,6 @@ const Layout = styled(Row)` width: 100vw; `; -export const Header = styled(Row)` - background-color: ${({ theme }) => theme.colors.white}; - box-shadow: 0px 2px 4px rgba(31, 41, 55, 0.06); - height: 46px; - width: 100%; - z-index: 1500; -`; - export const Content = styled(Column)` width: 100%; `; diff --git a/pages/services.tsx b/pages/services.tsx index dc961161..b1a7541f 100644 --- a/pages/services.tsx +++ b/pages/services.tsx @@ -41,18 +41,9 @@ const ServicesPage: FunctionComponent<ServicesPageProps> = (props) => { export async function getServerSideProps() { const { API_URL = '', - ARGO_CD_URL = '', - ARGO_WORKFLOWS_URL = '', - ATLANTIS_URL = '', DOMAIN_NAME = '', - GIT_PROVIDER = '', - GITHUB_OWNER = '', - GITLAB_OWNER = '', K3D_DOMAIN = '', KUBEFIRST_VERSION = '', - METAPHOR_DEVELOPMENT_URL = '', - METAPHOR_STAGING_URL = '', - METAPHOR_PRODUCTION_URL = '', USE_TELEMETRY = '', VAULT_URL = '', } = process.env; @@ -60,22 +51,11 @@ export async function getServerSideProps() { return { props: { apiUrl: API_URL, - argoUrl: ARGO_CD_URL, - argoWorkflowsUrl: ARGO_WORKFLOWS_URL, - atlantisUrl: ATLANTIS_URL, domainName: DOMAIN_NAME, - githubOwner: GITHUB_OWNER, - gitlabOwner: GITLAB_OWNER, - gitProvider: GIT_PROVIDER, k3dDomain: K3D_DOMAIN, kubefirstVersion: KUBEFIRST_VERSION, useTelemetry: USE_TELEMETRY === 'true', vaultUrl: VAULT_URL, - metaphor: { - development: METAPHOR_DEVELOPMENT_URL, - staging: METAPHOR_STAGING_URL, - production: METAPHOR_PRODUCTION_URL, - }, }, }; } diff --git a/redux/slices/api.slice.ts b/redux/slices/api.slice.ts index 89e5bbd6..9c93ab04 100644 --- a/redux/slices/api.slice.ts +++ b/redux/slices/api.slice.ts @@ -1,14 +1,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import { - createCluster, - deleteCluster, - getCluster, - getClusters, - getMarketplaceApps, -} from '../thunks/api.thunk'; +import { createCluster, deleteCluster, getCluster, getClusters } from '../thunks/api.thunk'; import { Cluster, ClusterStatus } from '../../types/provision'; -import { MarketplaceApp } from '../../types/marketplace'; export interface ApiState { loading: boolean; @@ -22,7 +15,6 @@ export interface ApiState { clusters: Array<Cluster>; selectedCluster?: Cluster; completedSteps: Array<{ label: string; order: number }>; - marketplaceApps: Array<MarketplaceApp>; } export const initialState: ApiState = { @@ -37,7 +29,6 @@ export const initialState: ApiState = { clusters: [], selectedCluster: undefined, completedSteps: [], - marketplaceApps: [], }; const apiSlice = createSlice({ @@ -105,13 +96,7 @@ const apiSlice = createSlice({ state.loading = false; state.isError = false; state.clusters = payload; - }) - .addCase( - getMarketplaceApps.fulfilled, - (state, { payload }: PayloadAction<Array<MarketplaceApp>>) => { - state.marketplaceApps = payload; - }, - ); + }); }, }); diff --git a/redux/slices/cluster.slice.ts b/redux/slices/cluster.slice.ts new file mode 100644 index 00000000..e0ceecb9 --- /dev/null +++ b/redux/slices/cluster.slice.ts @@ -0,0 +1,57 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { + getClusterServices, + getMarketplaceApps, + installMarketplaceApp, +} from '../../redux/thunks/api.thunk'; +import { Cluster, ClusterServices } from '../../types/provision'; +import { MarketplaceApp } from '../../types/marketplace'; + +export interface ConfigState { + selectedCluster?: Cluster; + clusterServices: Array<ClusterServices>; + marketplaceApps: Array<MarketplaceApp>; +} + +export const initialState: ConfigState = { + selectedCluster: undefined, + clusterServices: [], + marketplaceApps: [], +}; + +const clusterSlice = createSlice({ + name: 'cluster', + initialState, + reducers: { + setSelectedCluster: (state, { payload: cluster }: PayloadAction<Cluster>) => { + state.selectedCluster = cluster; + }, + }, + extraReducers: (builder) => { + builder + .addCase(getClusterServices.fulfilled, (state, { payload }) => { + state.clusterServices = payload; + }) + .addCase(installMarketplaceApp.fulfilled, (state, { payload }) => { + const { name, description, image_url } = payload; + state.clusterServices.push({ + default: false, + description: description as string, + name, + image: image_url, + links: [], + }); + }) + .addCase( + getMarketplaceApps.fulfilled, + (state, { payload }: PayloadAction<Array<MarketplaceApp>>) => { + state.marketplaceApps = payload; + }, + ); + }, +}); + +export const { setSelectedCluster } = clusterSlice.actions; + +export const clusterReducer = clusterSlice.reducer; diff --git a/redux/slices/index.ts b/redux/slices/index.ts index bf568181..94421e1e 100644 --- a/redux/slices/index.ts +++ b/redux/slices/index.ts @@ -4,3 +4,4 @@ export { configReducer } from './config.slice'; export { installationReducer } from './installation.slice'; export { apiReducer } from './api.slice'; export { featureFlagsReducer } from './featureFlags.slice'; +export { clusterReducer } from './cluster.slice'; diff --git a/redux/store.ts b/redux/store.ts index 2a485a4c..83fecc58 100644 --- a/redux/store.ts +++ b/redux/store.ts @@ -4,8 +4,9 @@ import { createWrapper } from 'next-redux-wrapper'; import { consoleApi } from './api'; import { - configReducer, apiReducer, + clusterReducer, + configReducer, featureFlagsReducer, gitReducer, installationReducer, @@ -22,6 +23,7 @@ export const makeStore = () => readiness: readinessReducer, api: apiReducer, featureFlags: featureFlagsReducer, + cluster: clusterReducer, }, middleware: (gDM) => gDM().concat(consoleApi.middleware), }); diff --git a/redux/thunks/api.thunk.ts b/redux/thunks/api.thunk.ts index ed200752..7604d8f8 100644 --- a/redux/thunks/api.thunk.ts +++ b/redux/thunks/api.thunk.ts @@ -1,9 +1,14 @@ import axios from 'axios'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { MarketplaceApp } from 'types/marketplace'; import { AppDispatch, RootState } from '../store'; -import { Cluster, ClusterRequestProps, ClusterResponse } from '../../types/provision'; +import { + Cluster, + ClusterRequestProps, + ClusterResponse, + ClusterServices, +} from '../../types/provision'; +import { MarketplaceApp, MarketplaceProps } from '../../types/marketplace'; const mapClusterFromRaw = (cluster: ClusterResponse): Cluster => ({ id: cluster._id, @@ -76,10 +81,7 @@ export const createCluster = createAsyncThunk< ...values?.vultr_auth, }, }; - const res = await axios.post( - `${apiUrl}/api/cluster/${values?.clusterName || 'kubefirst'}`, - params, - ); + const res = await axios.post(`${apiUrl}/cluster/${values?.clusterName || 'kubefirst'}`, params); if ('error' in res) { throw res.error; @@ -116,7 +118,8 @@ export const getClusters = createAsyncThunk< if ('error' in res) { throw res.error; } - return res.data.map(mapClusterFromRaw); + + return (res.data && res.data.map(mapClusterFromRaw)) || []; }); export const deleteCluster = createAsyncThunk< @@ -135,6 +138,22 @@ export const deleteCluster = createAsyncThunk< return res.data; }); +export const getClusterServices = createAsyncThunk< + Array<ClusterServices>, + ClusterRequestProps, + { + dispatch: AppDispatch; + state: RootState; + } +>('api/cluster/getClusterServices', async ({ apiUrl, clusterName }) => { + const res = await axios.get(`${apiUrl}/services/${clusterName}`); + + if ('error' in res) { + throw res.error; + } + return res.data?.services; +}); + export const getMarketplaceApps = createAsyncThunk< Array<MarketplaceApp>, void, @@ -152,5 +171,25 @@ export const getMarketplaceApps = createAsyncThunk< if ('error' in res) { throw res.error; } - return res.data.apps; + return res.data?.apps; +}); + +export const installMarketplaceApp = createAsyncThunk< + MarketplaceApp, + MarketplaceProps, + { + dispatch: AppDispatch; + state: RootState; + } +>('api/installMarketplaceApp', async ({ app, clusterName }, { getState }) => { + const { + config: { apiUrl }, + } = getState(); + + const res = await axios.post(`${apiUrl}/services/${clusterName}/${app.name}`); + + if ('error' in res) { + throw res.error; + } + return app; }); diff --git a/types/marketplace/index.ts b/types/marketplace/index.ts index 1f2b01d3..719ce3b0 100644 --- a/types/marketplace/index.ts +++ b/types/marketplace/index.ts @@ -5,3 +5,8 @@ export interface MarketplaceApp { description?: string; categories: Array<string>; } + +export interface MarketplaceProps { + app: MarketplaceApp; + clusterName: string; +} diff --git a/types/provision/index.ts b/types/provision/index.ts index e588f8cd..8b4dcc53 100644 --- a/types/provision/index.ts +++ b/types/provision/index.ts @@ -105,3 +105,12 @@ export interface Cluster extends Row { [key: string]: boolean; }; } + +export interface ClusterServices { + name: string; + default: boolean; + description: string; + image: string; + links: Array<string>; + status?: string; +} From 5dd71a33171b2c516362deee02331d6fc73c9600 Mon Sep 17 00:00:00 2001 From: CristhianF7 <CristhianF7@gmail.com> Date: Fri, 19 May 2023 01:08:25 -0500 Subject: [PATCH 3/6] feat: marketplace flow --- .../marketplaceCard/marketplaceCard.styled.ts | 3 +- components/marketplaceModal/index.tsx | 30 ++++-- components/service/service.styled.ts | 1 + components/tab/tab.styled.ts | 2 +- containers/header/header.styled.ts | 23 +++- containers/header/index.tsx | 12 ++- containers/marketplace/index.tsx | 34 ++++-- containers/marketplace/marketplace.styled.ts | 10 +- containers/service/index.tsx | 66 ++++++------ containers/services/index.tsx | 100 +++++++++++------- containers/services/services.styled.ts | 8 +- redux/slices/cluster.slice.ts | 74 ++++++++++++- types/marketplace/index.ts | 2 +- 13 files changed, 265 insertions(+), 100 deletions(-) diff --git a/components/marketplaceCard/marketplaceCard.styled.ts b/components/marketplaceCard/marketplaceCard.styled.ts index 7d24ab6f..a5061581 100644 --- a/components/marketplaceCard/marketplaceCard.styled.ts +++ b/components/marketplaceCard/marketplaceCard.styled.ts @@ -12,8 +12,9 @@ export const Card = styled.div` `; export const Description = styled(Typography)` - margin: 16px 0; color: ${({ theme }) => theme.colors.saltboxBlue}; + height: 100px; + margin: 16px 0; & a { color: ${({ theme }) => theme.colors.primary}; diff --git a/components/marketplaceModal/index.tsx b/components/marketplaceModal/index.tsx index fff0fe1e..7c92a84e 100644 --- a/components/marketplaceModal/index.tsx +++ b/components/marketplaceModal/index.tsx @@ -9,12 +9,15 @@ import Typography from '../typography'; import ControlledPassword from '../controlledFields/Password'; import { MarketplaceApp } from '../../types/marketplace'; import { BISCAY, SALTBOX_BLUE } from '../../constants/colors'; +import { useAppDispatch } from '../../redux/store'; +import { addMarketplaceApp } from '../../redux/slices/cluster.slice'; import { Content, Close, Footer, Header } from './marketplaceModal.styled'; export interface MarketplaceModalProps extends MarketplaceApp { isOpen: boolean; closeModal: () => void; + onSubmit: (name: string) => void; } const MarketplaceModal: FunctionComponent<MarketplaceModalProps> = ({ @@ -23,8 +26,23 @@ const MarketplaceModal: FunctionComponent<MarketplaceModalProps> = ({ name, image_url, secret_keys, + onSubmit, + ...rest }) => { - const { control } = useForm(); + const dispatch = useAppDispatch(); + const { + control, + formState: { isValid }, + } = useForm(); + + const handleSubmit = () => { + setTimeout(() => { + dispatch(addMarketplaceApp({ name, image_url, secret_keys, ...rest })); + closeModal(); + onSubmit(name); + }, 3000); + }; + return ( <Modal isOpen={isOpen} padding={0}> <Box @@ -46,12 +64,12 @@ const MarketplaceModal: FunctionComponent<MarketplaceModalProps> = ({ <Divider /> <Content> {secret_keys && - secret_keys.map((key) => ( + secret_keys.map(({ label, name }) => ( <ControlledPassword - key={key} + key={label} control={control} - name={key} - label={key} + name={name} + label={label} rules={{ required: true, }} @@ -64,7 +82,7 @@ const MarketplaceModal: FunctionComponent<MarketplaceModalProps> = ({ <Button variant="text" color="info" onClick={closeModal}> Cancel </Button> - <Button variant="contained" color="primary"> + <Button variant="contained" color="primary" disabled={!isValid} onClick={handleSubmit}> Add </Button> </Footer> diff --git a/components/service/service.styled.ts b/components/service/service.styled.ts index 489dcf22..8ca77b25 100644 --- a/components/service/service.styled.ts +++ b/components/service/service.styled.ts @@ -54,6 +54,7 @@ export const Image = styled(NextImage)` export const Title = styled(Typography)` color: ${({ theme }) => theme.colors.volcanicSand}; font-weight: 600; + text-transform: capitalize; `; export const Link = styled(NextLink)<{ disabled?: boolean }>` diff --git a/components/tab/tab.styled.ts b/components/tab/tab.styled.ts index 5bb03911..77d1a957 100644 --- a/components/tab/tab.styled.ts +++ b/components/tab/tab.styled.ts @@ -4,6 +4,6 @@ import { Box } from '@mui/material'; export const TabContainer = styled(Box)<{ backgroundColor?: string }>` background: ${({ backgroundColor }) => backgroundColor}; border-radius: 4px; - height: calc(100% - 122px); + height: 100%; width: 100%; `; diff --git a/containers/header/header.styled.ts b/containers/header/header.styled.ts index 59ea94c7..33c5414c 100644 --- a/containers/header/header.styled.ts +++ b/containers/header/header.styled.ts @@ -3,12 +3,31 @@ import styled from 'styled-components'; import row from '../../components/row'; export const Container = styled(row)` + align-items: center; background-color: ${({ theme }) => theme.colors.white}; box-shadow: 0px 2px 4px rgba(31, 41, 55, 0.06); + display: flex; height: 46px; + justify-content: center; width: 100%; z-index: 1500; `; -export const ClusterIndicator = styled.div``; -export const ClusterMenu = styled.div``; +export const ClusterIndicator = styled.div` + width: 8px; + height: 8px; + border-radius: 4px; + background: #22c55e; +`; + +export const ClusterMenu = styled.div` + align-items: center; + display: flex; + gap: 8px; + justify-content: center; + text-transform: uppercase; + + background: #fafafa; + border: 1px solid #f4f4f5; + padding: 0 8px; +`; diff --git a/containers/header/index.tsx b/containers/header/index.tsx index 7e6c34d0..26542a0e 100644 --- a/containers/header/index.tsx +++ b/containers/header/index.tsx @@ -5,6 +5,8 @@ import { setSelectedCluster } from 'redux/slices/cluster.slice'; import { getClusters } from '../../redux/thunks/api.thunk'; import Menu from '../../components/menu'; import { useAppDispatch, useAppSelector } from '../../redux/store'; +import Typography from '../../components/typography'; +import { SALTBOX_BLUE } from '../../constants/colors'; import { ClusterIndicator, ClusterMenu, Container } from './header.styled'; @@ -38,19 +40,21 @@ const Header: FunctionComponent = () => { return ( <Container> - {clusters?.length ? ( + {/* {clusters?.length ? ( <Menu onClickMenu={(cluster) => handleSelectCluster(cluster)} label={ <ClusterMenu> <ClusterIndicator /> - {selectedCluster?.clusterName} - <KeyboardArrowDownIcon /> + <Typography variant="body2" color={SALTBOX_BLUE}> + {selectedCluster?.clusterName} + </Typography> + <KeyboardArrowDownIcon htmlColor={SALTBOX_BLUE} /> </ClusterMenu> } options={clusters && clusters.map(({ clusterName }) => ({ label: clusterName }))} /> - ) : null} + ) : null} */} </Container> ); }; diff --git a/containers/marketplace/index.tsx b/containers/marketplace/index.tsx index 1492fb2f..a1716c68 100644 --- a/containers/marketplace/index.tsx +++ b/containers/marketplace/index.tsx @@ -1,14 +1,16 @@ import React, { FunctionComponent, useMemo, useState } from 'react'; import { FormControlLabel, FormGroup } from '@mui/material'; import intersection from 'lodash/intersection'; +import sortBy from 'lodash/sortBy'; import NextLink from 'next/link'; +import { addMarketplaceApp } from 'redux/slices/cluster.slice'; import Checkbox from '../../components/checkbox'; import Typography from '../../components/typography'; import MarketplaceCard from '../../components/marketplaceCard'; import MarketplaceModal from '../../components/marketplaceModal'; import useModal from '../../hooks/useModal'; -import { useAppSelector } from '../../redux/store'; +import { useAppDispatch, useAppSelector } from '../../redux/store'; import { MarketplaceApp } from '../../types/marketplace'; import { VOLCANIC_SAND } from '../../constants/colors'; @@ -20,13 +22,19 @@ const STATIC_HELP_CARD: MarketplaceApp = { image_url: 'https://assets.kubefirst.com/console/help.png', }; -const Marketplace: FunctionComponent = () => { +const Marketplace: FunctionComponent<{ onSubmit: (name: string) => void }> = ({ onSubmit }) => { const [selectedCategories, setSelectedCategories] = useState<Array<string>>([]); const [selectedApp, setSelectedApp] = useState<MarketplaceApp>(); + const dispatch = useAppDispatch(); + const { isOpen, openModal, closeModal } = useModal(); - const marketplaceApps = useAppSelector(({ cluster }) => cluster.marketplaceApps); + const marketplaceApps = useAppSelector(({ cluster }) => + cluster.marketplaceApps.filter( + (app) => !cluster.clusterServices.map((s) => s.name).includes(app.name), + ), + ); const categories = useMemo( () => marketplaceApps @@ -51,8 +59,15 @@ const Marketplace: FunctionComponent = () => { }; const handleSelectedApp = (app: MarketplaceApp) => { - setSelectedApp(app); - openModal(); + if (app.secret_keys?.length) { + setSelectedApp(app); + openModal(); + } else { + setTimeout(() => { + dispatch(addMarketplaceApp(app)); + onSubmit(app.name); + }, 2000); + } }; const filteredApps = useMemo(() => { @@ -88,7 +103,7 @@ const Marketplace: FunctionComponent = () => { /> </FormGroup> {categories && - categories.map((category) => ( + sortBy(categories).map((category) => ( <FormGroup key={category} sx={{ mb: 2 }}> <FormControlLabel control={<Checkbox sx={{ mr: 2 }} onClick={() => onClickCategory(category)} />} @@ -131,7 +146,12 @@ const Marketplace: FunctionComponent = () => { </CardsContainer> </Content> {isOpen && selectedApp?.name && ( - <MarketplaceModal closeModal={closeModal} isOpen={isOpen} {...selectedApp} /> + <MarketplaceModal + closeModal={closeModal} + isOpen={isOpen} + onSubmit={onSubmit} + {...selectedApp} + /> )} </Container> ); diff --git a/containers/marketplace/marketplace.styled.ts b/containers/marketplace/marketplace.styled.ts index d4938252..cfce0a84 100644 --- a/containers/marketplace/marketplace.styled.ts +++ b/containers/marketplace/marketplace.styled.ts @@ -1,21 +1,22 @@ import styled from 'styled-components'; -import Typography from '../../components/typography'; - export const CardsContainer = styled.div` display: flex; flex-wrap: wrap; gap: 16px; margin-top: 24px; + overflow: auto; `; export const Container = styled.div` display: flex; - height: calc(100% - 30px); + height: calc(100% - 80px); width: 100%; `; export const Content = styled.div` + height: calc(100% - 30px); + overflow: auto; padding: 24px; width: 100%; `; @@ -26,6 +27,7 @@ export const Filter = styled.div` border-style: solid; border-color: ${({ theme }) => theme.colors.pastelLightBlue}; border-radius: 8px; - padding: 24px; + height: 100%; + padding: 24px 24px 0 24px; width: 266px; `; diff --git a/containers/service/index.tsx b/containers/service/index.tsx index ad600ef4..da6a41f1 100644 --- a/containers/service/index.tsx +++ b/containers/service/index.tsx @@ -46,41 +46,41 @@ const Service: FunctionComponent<ServiceProps> = ({ links: serviceLinks, ...prop [dispatch, isSiteAvailable], ); - useEffect(() => { - if (availableSites.length) { - setLinks( - links && - Object.keys(links).reduce( - (previous, current) => ({ ...previous, [current]: isSiteAvailable(current) }), - {}, - ), - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [availableSites]); + // useEffect(() => { + // if (availableSites.length) { + // setLinks( + // links && + // Object.keys(links).reduce( + // (previous, current) => ({ ...previous, [current]: isSiteAvailable(current) }), + // {}, + // ), + // ); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [availableSites]); - useEffect(() => { - const interval = setInterval( - () => - links && - Object.keys(links).map((url) => { - const isAvailable = links[url]; - !isAvailable && checkSiteAvailability(url); - }), - 20000, - ); - return () => clearInterval(interval); - }); + // useEffect(() => { + // const interval = setInterval( + // () => + // links && + // Object.keys(links).map((url) => { + // const isAvailable = links[url]; + // !isAvailable && checkSiteAvailability(url); + // }), + // 20000, + // ); + // return () => clearInterval(interval); + // }); - useEffect(() => { - if (!firstLoad) { - setFirstLoad(true); - links && - Object.keys(links).map(async (url) => { - await checkSiteAvailability(url); - }); - } - }, [checkSiteAvailability, dispatch, firstLoad, links]); + // useEffect(() => { + // if (!firstLoad) { + // setFirstLoad(true); + // links && + // Object.keys(links).map(async (url) => { + // await checkSiteAvailability(url); + // }); + // } + // }, [checkSiteAvailability, dispatch, firstLoad, links]); return <ServiceComponent {...props} links={links} />; }; diff --git a/containers/services/index.tsx b/containers/services/index.tsx index a250cca9..3823b18a 100644 --- a/containers/services/index.tsx +++ b/containers/services/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, useEffect, useCallback, useState } from 'react'; -import { Box, Tabs } from '@mui/material'; +import { Alert, Box, Snackbar, Tabs } from '@mui/material'; import Service from '../service'; import Marketplace from '../marketplace'; @@ -9,10 +9,11 @@ import { useTelemetryMutation } from '../../redux/api'; import { setConfigValues } from '../../redux/slices/config.slice'; import { getMarketplaceApps } from '../../redux/thunks/api.thunk'; import { useAppDispatch, useAppSelector } from '../../redux/store'; +import useToggle from '../../hooks/useToggle'; import { DOCS_LINK } from '../../constants'; import { BISCAY, SALTBOX_BLUE, VOLCANIC_SAND } from '../../constants/colors'; -import { Container, Header, LearnMoreLink, ServicesContainer } from './services.styled'; +import { Container, Content, Header, LearnMoreLink, ServicesContainer } from './services.styled'; enum SERVICES_TABS { PROVISIONED = 0, @@ -35,7 +36,9 @@ const Services: FunctionComponent<ServicesProps> = ({ kubefirstVersion, useTelemetry, }) => { + const [marketplaceApp, setMarketplaceApp] = useState<string>(''); const [activeTab, setActiveTab] = useState<number>(0); + const { isOpen, open, close } = useToggle(); const [sendTelemetryEvent] = useTelemetryMutation(); const { isTelemetryEnabled, clusterServices } = useAppSelector(({ config, cluster }) => ({ @@ -87,42 +90,63 @@ const Services: FunctionComponent<ServicesProps> = ({ /> </Tabs> </Box> - <TabPanel value={activeTab} index={SERVICES_TABS.PROVISIONED}> - <Typography variant="body2" sx={{ mb: 3 }} color={VOLCANIC_SAND}> - Click on a link to access the service Kubefirst has provisioned for you.{' '} - <LearnMoreLink - href={DOCS_LINK} - target="_blank" - onClick={() => onClickLink(DOCS_LINK, 'docs')} - > - Learn more - </LearnMoreLink> - </Typography> - <ServicesContainer> - {clusterServices.map(({ name, ...rest }) => ( - <Service - key={name} - name={name} - {...rest} - onClickLink={onClickLink} - domainName={domainName} - /> - ))} - </ServicesContainer> - </TabPanel> - <TabPanel value={activeTab} index={SERVICES_TABS.MARKETPLACE}> - <Typography variant="body2" sx={{ mb: 3 }} color={VOLCANIC_SAND}> - Add your favourite applications to your cluster.{' '} - <LearnMoreLink - href={DOCS_LINK} - target="_blank" - onClick={() => onClickLink(DOCS_LINK, 'docs')} - > - Learn more - </LearnMoreLink> - </Typography> - <Marketplace /> - </TabPanel> + <Content> + <TabPanel value={activeTab} index={SERVICES_TABS.PROVISIONED}> + <Typography variant="body2" sx={{ mb: 3 }} color={VOLCANIC_SAND}> + Click on a link to access the service Kubefirst has provisioned for you.{' '} + <LearnMoreLink + href={DOCS_LINK} + target="_blank" + onClick={() => onClickLink(DOCS_LINK, 'docs')} + > + Learn more + </LearnMoreLink> + </Typography> + <ServicesContainer> + {clusterServices.map(({ name, ...rest }) => ( + <Service + key={name} + name={name} + {...rest} + onClickLink={onClickLink} + domainName={domainName} + /> + ))} + </ServicesContainer> + </TabPanel> + <TabPanel value={activeTab} index={SERVICES_TABS.MARKETPLACE}> + <Typography variant="body2" sx={{ mb: 3 }} color={VOLCANIC_SAND}> + Add your favourite applications to your cluster.{' '} + <LearnMoreLink + href={DOCS_LINK} + target="_blank" + onClick={() => onClickLink(DOCS_LINK, 'docs')} + > + Learn more + </LearnMoreLink> + </Typography> + <Marketplace + onSubmit={(name: string) => { + setMarketplaceApp(name); + setActiveTab(SERVICES_TABS.PROVISIONED); + open(); + }} + /> + </TabPanel> + </Content> + <Snackbar + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + open={isOpen} + autoHideDuration={5000} + onClose={close} + > + <Alert onClose={close} severity="success" sx={{ width: '100%' }} variant="filled"> + {`${marketplaceApp} successfully added to your cluster!`} + </Alert> + </Snackbar> </Container> ); }; diff --git a/containers/services/services.styled.ts b/containers/services/services.styled.ts index c7edd832..f241d3b4 100644 --- a/containers/services/services.styled.ts +++ b/containers/services/services.styled.ts @@ -4,10 +4,14 @@ import styled from 'styled-components'; export const Container = styled.div` height: calc(100vh - 80px); margin: 0 auto; - padding: 40px; + padding-top: 40px; width: 1192px; `; +export const Content = styled.div` + height: calc(100% - 140px); +`; + export const Header = styled.div` color: ${({ theme }) => theme.colors.volcanicSand}; display: flex; @@ -25,4 +29,6 @@ export const ServicesContainer = styled.div` display: flex; gap: 16px; flex-wrap: wrap; + max-height: calc(100% - 50px); + overflow: auto; `; diff --git a/redux/slices/cluster.slice.ts b/redux/slices/cluster.slice.ts index e0ceecb9..67dbe913 100644 --- a/redux/slices/cluster.slice.ts +++ b/redux/slices/cluster.slice.ts @@ -16,7 +16,66 @@ export interface ConfigState { export const initialState: ConfigState = { selectedCluster: undefined, - clusterServices: [], + clusterServices: [ + { + name: 'Argo CD', + default: true, + description: + 'A GitOps oriented continuous delivery tool for managing all of our applications across our Kubernetes clusters.', + image: 'https://assets.kubefirst.com/console/argocd.svg', + links: ['https://argocd.kubesecond.net'], + status: '', + }, + { + name: 'Argo Workflows', + default: true, + description: 'The workflow engine for orchestrating parallel jobs on Kubernetes.', + image: 'https://assets.kubefirst.com/console/argocd.svg', + links: ['https://argo.kubesecond.net/workflows'], + status: '', + }, + { + name: 'Atlantis', + default: true, + description: 'Kubefirst manages Terraform workflows with Atlantis automation.', + image: 'https://assets.kubefirst.com/console/atlantis.svg', + links: ['https://atlantis.kubesecond.net'], + status: '', + }, + { + name: 'github', + default: true, + description: + 'The git repositories contain all the Infrastructure as Code and GitOps configurations.', + image: 'https://assets.kubefirst.com/console/github.svg', // or gitlab https://assets.kubefirst.com/console/gitlab.svg + links: [ + 'https://github.com/kubefirst-test/gitops', + 'https://github.com/kubefirst-test/metaphor', + ], + status: '', + }, + { + name: 'Metaphor', + default: true, + description: + "A multi-environment demonstration space for frontend application best practices that's easy to apply to other projects.", + image: 'https://assets.kubefirst.com/console/metaphor.svg', + links: [ + 'https://metaphor-development.kubesecond.net', + 'https://metaphor-staging.kubesecond.net', + 'https://metaphor-production.kubesecond.net', + ], + status: '', + }, + { + name: 'Vault', + default: true, + description: "Kubefirst's secrets manager and identity provider.", + image: 'https://assets.kubefirst.com/console/vault.svg', + links: ['https://vault.kubesecond.net'], + status: '', + }, + ], marketplaceApps: [], }; @@ -27,6 +86,17 @@ const clusterSlice = createSlice({ setSelectedCluster: (state, { payload: cluster }: PayloadAction<Cluster>) => { state.selectedCluster = cluster; }, + addMarketplaceApp: (state, { payload: app }: PayloadAction<MarketplaceApp>) => { + console.log(app); + const { name, description, image_url } = app; + state.clusterServices.push({ + default: false, + description: description as string, + name, + image: image_url, + links: [], + }); + }, }, extraReducers: (builder) => { builder @@ -52,6 +122,6 @@ const clusterSlice = createSlice({ }, }); -export const { setSelectedCluster } = clusterSlice.actions; +export const { addMarketplaceApp, setSelectedCluster } = clusterSlice.actions; export const clusterReducer = clusterSlice.reducer; diff --git a/types/marketplace/index.ts b/types/marketplace/index.ts index 719ce3b0..f85c0508 100644 --- a/types/marketplace/index.ts +++ b/types/marketplace/index.ts @@ -1,6 +1,6 @@ export interface MarketplaceApp { name: string; - secret_keys?: Array<string>; + secret_keys?: Array<{ name: string; label: string }>; image_url: string; description?: string; categories: Array<string>; From a2eaf3a423ee4e370ecb2d96b34983f9f080d512 Mon Sep 17 00:00:00 2001 From: CristhianF7 <CristhianF7@gmail.com> Date: Sun, 21 May 2023 22:22:18 -0500 Subject: [PATCH 4/6] feat: marketplace api integration refactor --- components/marketplaceModal/index.tsx | 22 ++---- components/menu/index.tsx | 1 + components/navigation/index.tsx | 72 ++++++------------- components/table/index.tsx | 4 +- containers/clusterManagement/index.tsx | 11 ++- containers/header/header.styled.ts | 11 ++- containers/header/index.tsx | 8 ++- containers/marketplace/index.tsx | 65 +++++++++++------ containers/marketplace/marketplace.styled.ts | 1 + containers/navigation/index.tsx | 75 ++++++++++++++++++++ containers/provision/index.tsx | 11 ++- containers/services/index.tsx | 64 ++++++++--------- pages/_app.tsx | 2 +- pages/cluster-management.tsx | 3 +- pages/index.ts | 12 ++-- pages/provision.tsx | 9 +-- pages/services.tsx | 32 +-------- redux/slices/cluster.slice.ts | 74 +------------------ redux/thunks/api.thunk.ts | 6 +- 19 files changed, 229 insertions(+), 254 deletions(-) create mode 100644 containers/navigation/index.tsx diff --git a/components/marketplaceModal/index.tsx b/components/marketplaceModal/index.tsx index 7c92a84e..1ab6bc5b 100644 --- a/components/marketplaceModal/index.tsx +++ b/components/marketplaceModal/index.tsx @@ -1,7 +1,7 @@ import React, { FunctionComponent } from 'react'; import { Box, Divider } from '@mui/material'; import Image from 'next/image'; -import { useForm } from 'react-hook-form'; +import { Control } from 'react-hook-form'; import Modal from '../modal'; import Button from '../button'; @@ -9,38 +9,30 @@ import Typography from '../typography'; import ControlledPassword from '../controlledFields/Password'; import { MarketplaceApp } from '../../types/marketplace'; import { BISCAY, SALTBOX_BLUE } from '../../constants/colors'; -import { useAppDispatch } from '../../redux/store'; -import { addMarketplaceApp } from '../../redux/slices/cluster.slice'; import { Content, Close, Footer, Header } from './marketplaceModal.styled'; export interface MarketplaceModalProps extends MarketplaceApp { + control: Control; isOpen: boolean; + isValid: boolean; closeModal: () => void; - onSubmit: (name: string) => void; + onSubmit: (app: MarketplaceApp) => void; } const MarketplaceModal: FunctionComponent<MarketplaceModalProps> = ({ + control, closeModal, isOpen, + isValid, name, image_url, secret_keys, onSubmit, ...rest }) => { - const dispatch = useAppDispatch(); - const { - control, - formState: { isValid }, - } = useForm(); - const handleSubmit = () => { - setTimeout(() => { - dispatch(addMarketplaceApp({ name, image_url, secret_keys, ...rest })); - closeModal(); - onSubmit(name); - }, 3000); + onSubmit({ name, image_url, secret_keys, ...rest }); }; return ( diff --git a/components/menu/index.tsx b/components/menu/index.tsx index f3ded3c9..1872f692 100644 --- a/components/menu/index.tsx +++ b/components/menu/index.tsx @@ -44,6 +44,7 @@ const Menu: FunctionComponent<MenuProps> = ({ isDisabled, label, options, onClic onClick={handleClick} disableRipple disabled={isDisabled} + sx={{ padding: 0 }} > {label} </Button> diff --git a/components/navigation/index.tsx b/components/navigation/index.tsx index 07e7f854..e239ace4 100644 --- a/components/navigation/index.tsx +++ b/components/navigation/index.tsx @@ -1,16 +1,10 @@ -import React, { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import Image from 'next/image'; -import { useRouter } from 'next/router'; import HelpIcon from '@mui/icons-material/Help'; -import ScatterPlotIcon from '@mui/icons-material/ScatterPlot'; -// import PeopleOutlineSharpIcon from '@mui/icons-material/PeopleOutlineSharp'; -import GridViewOutlinedIcon from '@mui/icons-material/GridViewOutlined'; import { BsSlack } from 'react-icons/bs'; import Link from 'next/link'; import { ECHO_BLUE } from '../../constants/colors'; -import { useAppSelector } from '../../redux/store'; -import useFeatureFlag from '../../hooks/useFeatureFlag'; import { Container, @@ -35,50 +29,24 @@ const FOOTER_ITEMS = [ }, ]; -const Navigation: FunctionComponent = () => { - const [domLoaded, setDomLoaded] = useState(false); - const { asPath } = useRouter(); - const { kubefirstVersion } = useAppSelector(({ config }) => config); - const { isEnabled, flagsAreReady } = useFeatureFlag('cluster-management'); - - const routes = useMemo( - () => - [ - { - icon: <ScatterPlotIcon />, - path: '/cluster-management', - title: 'Cluster Management', - isEnabled: flagsAreReady && isEnabled, - }, - { - icon: <GridViewOutlinedIcon />, - path: '/services', - title: 'Services', - isEnabled: true, - }, - ].filter(({ isEnabled }) => isEnabled), - [flagsAreReady, isEnabled], - ); - - const isActive = useCallback( - (route: string) => { - if (typeof window !== 'undefined') { - const linkPathname = new URL(route, window?.location?.href).pathname; - - // Using URL().pathname to get rid of query and hash - const activePathname = new URL(asPath, window?.location?.href).pathname; - - return linkPathname === activePathname; - } - return false; - }, - [asPath], - ); - - useEffect(() => { - setDomLoaded(true); - }, []); +export interface NavigationProps { + domLoaded: boolean; + handleIsActiveItem: (path: string) => void; + kubefirstVersion?: string; + routes: Array<{ + icon: ReactNode; + path: string; + title: string; + isEnabled: boolean; + }>; +} +const Navigation: FunctionComponent<NavigationProps> = ({ + domLoaded, + handleIsActiveItem, + kubefirstVersion, + routes, +}) => { return ( <Container> <div> @@ -92,11 +60,11 @@ const Navigation: FunctionComponent = () => { </KubefirstVersion> )} </KubefirstTitle> - {domLoaded && flagsAreReady && ( + {domLoaded && ( <MenuContainer> {routes.map(({ icon, path, title }) => ( <Link href={path} key={path}> - <MenuItem isActive={isActive(path)}> + <MenuItem isActive={handleIsActiveItem(path)}> {icon} <Title variant="body1">{title}</Title> </MenuItem> diff --git a/components/table/index.tsx b/components/table/index.tsx index 645ecac7..cbe89176 100644 --- a/components/table/index.tsx +++ b/components/table/index.tsx @@ -57,8 +57,8 @@ const Table: FunctionComponent<DataGridProps> = ({ ...props }) => { padding: '0 16px', }, [`.${gridClasses.main}`]: { - 'background': 'white', - 'border-radius': '4px', + background: 'white', + borderRadius: '4px', }, 'filter': 'drop-shadow(0px 4px 12px rgba(0, 0, 0, 0.04))', 'border': 0, diff --git a/containers/clusterManagement/index.tsx b/containers/clusterManagement/index.tsx index 43e6414f..f525876f 100644 --- a/containers/clusterManagement/index.tsx +++ b/containers/clusterManagement/index.tsx @@ -24,9 +24,14 @@ import { getClusterManagementColumns, getClusterState } from './columnDefinition export interface ClusterManagementProps { apiUrl: string; useTelemetry: boolean; + kubefirstVersion: string; } -const ClusterManagement: FunctionComponent<ClusterManagementProps> = ({ apiUrl, useTelemetry }) => { +const ClusterManagement: FunctionComponent<ClusterManagementProps> = ({ + apiUrl, + kubefirstVersion, + useTelemetry, +}) => { const [selectedCluster, setSelectedCluster] = useState<Cluster>(); const { isOpen: isDetailsPanelOpen, @@ -104,8 +109,8 @@ const ClusterManagement: FunctionComponent<ClusterManagementProps> = ({ apiUrl, }, [apiUrl, dispatch, handleGetClusters]); useEffect(() => { - dispatch(setConfigValues({ isTelemetryEnabled: useTelemetry, apiUrl })); - }, [dispatch, useTelemetry, apiUrl]); + dispatch(setConfigValues({ isTelemetryEnabled: useTelemetry, apiUrl, kubefirstVersion })); + }, [dispatch, useTelemetry, apiUrl, kubefirstVersion]); return ( <Container> diff --git a/containers/header/header.styled.ts b/containers/header/header.styled.ts index 33c5414c..a60e7e5c 100644 --- a/containers/header/header.styled.ts +++ b/containers/header/header.styled.ts @@ -7,10 +7,9 @@ export const Container = styled(row)` background-color: ${({ theme }) => theme.colors.white}; box-shadow: 0px 2px 4px rgba(31, 41, 55, 0.06); display: flex; - height: 46px; + min-height: 64px; justify-content: center; width: 100%; - z-index: 1500; `; export const ClusterIndicator = styled.div` @@ -22,12 +21,12 @@ export const ClusterIndicator = styled.div` export const ClusterMenu = styled.div` align-items: center; + background: #fafafa; + border: 1px solid #f4f4f5; display: flex; + height: 28px; gap: 8px; justify-content: center; - text-transform: uppercase; - - background: #fafafa; - border: 1px solid #f4f4f5; padding: 0 8px; + text-transform: uppercase; `; diff --git a/containers/header/index.tsx b/containers/header/index.tsx index 26542a0e..fbb31b91 100644 --- a/containers/header/index.tsx +++ b/containers/header/index.tsx @@ -40,7 +40,7 @@ const Header: FunctionComponent = () => { return ( <Container> - {/* {clusters?.length ? ( + {clusters?.length ? ( <Menu onClickMenu={(cluster) => handleSelectCluster(cluster)} label={ @@ -52,9 +52,11 @@ const Header: FunctionComponent = () => { <KeyboardArrowDownIcon htmlColor={SALTBOX_BLUE} /> </ClusterMenu> } - options={clusters && clusters.map(({ clusterName }) => ({ label: clusterName }))} + options={ + clusters && clusters.map(({ clusterName }) => ({ label: clusterName.toUpperCase() })) + } /> - ) : null} */} + ) : null} </Container> ); }; diff --git a/containers/marketplace/index.tsx b/containers/marketplace/index.tsx index a1716c68..76a80646 100644 --- a/containers/marketplace/index.tsx +++ b/containers/marketplace/index.tsx @@ -1,16 +1,18 @@ import React, { FunctionComponent, useMemo, useState } from 'react'; -import { FormControlLabel, FormGroup } from '@mui/material'; +import { useForm } from 'react-hook-form'; +import NextLink from 'next/link'; import intersection from 'lodash/intersection'; import sortBy from 'lodash/sortBy'; -import NextLink from 'next/link'; -import { addMarketplaceApp } from 'redux/slices/cluster.slice'; +import { Alert, FormControlLabel, FormGroup, Snackbar } from '@mui/material'; import Checkbox from '../../components/checkbox'; import Typography from '../../components/typography'; import MarketplaceCard from '../../components/marketplaceCard'; import MarketplaceModal from '../../components/marketplaceModal'; import useModal from '../../hooks/useModal'; +import useToggle from '../../hooks/useToggle'; import { useAppDispatch, useAppSelector } from '../../redux/store'; +import { installMarketplaceApp } from '../../redux/thunks/api.thunk'; import { MarketplaceApp } from '../../types/marketplace'; import { VOLCANIC_SAND } from '../../constants/colors'; @@ -22,19 +24,27 @@ const STATIC_HELP_CARD: MarketplaceApp = { image_url: 'https://assets.kubefirst.com/console/help.png', }; -const Marketplace: FunctionComponent<{ onSubmit: (name: string) => void }> = ({ onSubmit }) => { +const Marketplace: FunctionComponent<{ onSubmit: () => void }> = ({ onSubmit }) => { const [selectedCategories, setSelectedCategories] = useState<Array<string>>([]); const [selectedApp, setSelectedApp] = useState<MarketplaceApp>(); const dispatch = useAppDispatch(); + const selectedCluster = useAppSelector(({ cluster }) => cluster.selectedCluster); const { isOpen, openModal, closeModal } = useModal(); + const { isOpen: isNotificationOpen, open, close } = useToggle(); + + const { + control, + formState: { isValid }, + } = useForm(); const marketplaceApps = useAppSelector(({ cluster }) => cluster.marketplaceApps.filter( (app) => !cluster.clusterServices.map((s) => s.name).includes(app.name), ), ); + const categories = useMemo( () => marketplaceApps @@ -58,15 +68,24 @@ const Marketplace: FunctionComponent<{ onSubmit: (name: string) => void }> = ({ } }; + const handleAddMarketplaceApp = async (app: MarketplaceApp) => { + try { + await dispatch( + installMarketplaceApp({ app, clusterName: selectedCluster?.clusterName as string }), + ); + open(); + onSubmit(); + } catch (error) { + //todo: handle error + } + }; + const handleSelectedApp = (app: MarketplaceApp) => { if (app.secret_keys?.length) { setSelectedApp(app); openModal(); } else { - setTimeout(() => { - dispatch(addMarketplaceApp(app)); - onSubmit(app.name); - }, 2000); + handleAddMarketplaceApp(app); } }; @@ -89,19 +108,6 @@ const Marketplace: FunctionComponent<{ onSubmit: (name: string) => void }> = ({ <Typography variant="subtitle2" sx={{ mb: 3 }}> Category </Typography> - <FormGroup sx={{ mb: 2 }}> - <FormControlLabel - control={ - <Checkbox sx={{ mr: 2 }} onClick={() => onClickCategory('all')} defaultChecked /> - } - label={ - <Typography variant="body2" color={VOLCANIC_SAND}> - All - </Typography> - } - sx={{ ml: 0 }} - /> - </FormGroup> {categories && sortBy(categories).map((category) => ( <FormGroup key={category} sx={{ mb: 2 }}> @@ -147,12 +153,27 @@ const Marketplace: FunctionComponent<{ onSubmit: (name: string) => void }> = ({ </Content> {isOpen && selectedApp?.name && ( <MarketplaceModal + control={control} + isValid={isValid} closeModal={closeModal} isOpen={isOpen} - onSubmit={onSubmit} + onSubmit={handleAddMarketplaceApp} {...selectedApp} /> )} + <Snackbar + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + open={isNotificationOpen} + autoHideDuration={5000} + onClose={close} + > + <Alert onClose={close} severity="success" sx={{ width: '100%' }} variant="filled"> + {`${selectedApp?.name} successfully added to your cluster!`} + </Alert> + </Snackbar> </Container> ); }; diff --git a/containers/marketplace/marketplace.styled.ts b/containers/marketplace/marketplace.styled.ts index cfce0a84..032006c6 100644 --- a/containers/marketplace/marketplace.styled.ts +++ b/containers/marketplace/marketplace.styled.ts @@ -28,6 +28,7 @@ export const Filter = styled.div` border-color: ${({ theme }) => theme.colors.pastelLightBlue}; border-radius: 8px; height: 100%; + overflow: auto; padding: 24px 24px 0 24px; width: 266px; `; diff --git a/containers/navigation/index.tsx b/containers/navigation/index.tsx new file mode 100644 index 00000000..b6c13bc6 --- /dev/null +++ b/containers/navigation/index.tsx @@ -0,0 +1,75 @@ +import React, { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import ScatterPlotIcon from '@mui/icons-material/ScatterPlot'; +import GridViewOutlinedIcon from '@mui/icons-material/GridViewOutlined'; + +import NavigationComponent from '../../components/navigation'; +import useFeatureFlag from '../../hooks/useFeatureFlag'; +import { useAppSelector } from '../../redux/store'; + +const Navigation: FunctionComponent = () => { + const [domLoaded, setDomLoaded] = useState<boolean>(false); + const { asPath } = useRouter(); + const { kubefirstVersion, selectedCluster } = useAppSelector(({ config, cluster }) => ({ + kubefirstVersion: config.kubefirstVersion, + selectedCluster: cluster.selectedCluster, + })); + const { isEnabled, flagsAreReady } = useFeatureFlag('cluster-management'); + const { isEnabled: isClusterProvisioningEnabled } = useFeatureFlag('cluster-provisioning'); + + const routes = useMemo( + () => + [ + { + icon: <ScatterPlotIcon />, + path: '/cluster-management', + title: 'Cluster Management', + isEnabled: flagsAreReady && isEnabled, + }, + { + icon: <ScatterPlotIcon />, + path: '/provision', + title: 'Cluster Provisioning', + isEnabled: flagsAreReady && isClusterProvisioningEnabled, + }, + { + icon: <GridViewOutlinedIcon />, + path: '/services', + title: 'Services', + isEnabled: !!selectedCluster?.clusterName, + }, + ].filter(({ isEnabled }) => isEnabled), + [flagsAreReady, isClusterProvisioningEnabled, isEnabled, selectedCluster?.clusterName], + ); + + const handleIsActiveItem = useCallback( + (route: string) => { + if (typeof window !== 'undefined') { + const linkPathname = new URL(route, window?.location?.href).pathname; + + // Using URL().pathname to get rid of query and hash + const activePathname = new URL(asPath, window?.location?.href).pathname; + + return linkPathname === activePathname; + } + + return false; + }, + [asPath], + ); + + useEffect(() => { + setDomLoaded(true); + }, []); + + return ( + <NavigationComponent + domLoaded={domLoaded} + kubefirstVersion={kubefirstVersion} + routes={routes} + handleIsActiveItem={handleIsActiveItem} + /> + ); +}; + +export default Navigation; diff --git a/containers/provision/index.tsx b/containers/provision/index.tsx index e2d0db4a..7f56e88f 100644 --- a/containers/provision/index.tsx +++ b/containers/provision/index.tsx @@ -25,10 +25,15 @@ import { AdvancedOptionsContainer, ErrorContainer, Form, FormContent } from './p export interface ProvisionProps { apiUrl: string; + kubefirstVersion: string; useTelemetry: boolean; } -const Provision: FunctionComponent<ProvisionProps> = ({ apiUrl, useTelemetry }) => { +const Provision: FunctionComponent<ProvisionProps> = ({ + apiUrl, + kubefirstVersion, + useTelemetry, +}) => { const dispatch = useAppDispatch(); const { installType, gitProvider, installationStep, values, error } = useAppSelector( ({ installation }) => installation, @@ -188,12 +193,12 @@ const Provision: FunctionComponent<ProvisionProps> = ({ apiUrl, useTelemetry }) ]); useEffect(() => { - dispatch(setConfigValues({ isTelemetryEnabled: useTelemetry, apiUrl })); + dispatch(setConfigValues({ isTelemetryEnabled: useTelemetry, apiUrl, kubefirstVersion })); return () => { dispatch(resetInstallState()); }; - }, [dispatch, useTelemetry, apiUrl]); + }, [dispatch, useTelemetry, apiUrl, kubefirstVersion]); return ( <Form component="form" onSubmit={handleSubmit(onSubmit)}> diff --git a/containers/services/index.tsx b/containers/services/index.tsx index 3823b18a..b91ed039 100644 --- a/containers/services/index.tsx +++ b/containers/services/index.tsx @@ -1,15 +1,16 @@ import React, { FunctionComponent, useEffect, useCallback, useState } from 'react'; -import { Alert, Box, Snackbar, Tabs } from '@mui/material'; +import { Box, Tabs } from '@mui/material'; +import { useRouter } from 'next/router'; import Service from '../service'; import Marketplace from '../marketplace'; import TabPanel, { Tab, a11yProps } from '../../components/tab'; import Typography from '../../components/typography'; +import useFeatureFlag from '../../hooks/useFeatureFlag'; import { useTelemetryMutation } from '../../redux/api'; import { setConfigValues } from '../../redux/slices/config.slice'; -import { getMarketplaceApps } from '../../redux/thunks/api.thunk'; +import { getClusterServices, getMarketplaceApps } from '../../redux/thunks/api.thunk'; import { useAppDispatch, useAppSelector } from '../../redux/store'; -import useToggle from '../../hooks/useToggle'; import { DOCS_LINK } from '../../constants'; import { BISCAY, SALTBOX_BLUE, VOLCANIC_SAND } from '../../constants/colors'; @@ -22,7 +23,6 @@ enum SERVICES_TABS { export interface ServicesProps { apiUrl: string; - atlantisUrl: string; domainName: string; k3dDomain: string; kubefirstVersion: string; @@ -36,17 +36,20 @@ const Services: FunctionComponent<ServicesProps> = ({ kubefirstVersion, useTelemetry, }) => { - const [marketplaceApp, setMarketplaceApp] = useState<string>(''); const [activeTab, setActiveTab] = useState<number>(0); - const { isOpen, open, close } = useToggle(); const [sendTelemetryEvent] = useTelemetryMutation(); + const router = useRouter(); - const { isTelemetryEnabled, clusterServices } = useAppSelector(({ config, cluster }) => ({ - isTelemetryEnabled: config.isTelemetryEnabled, - clusterServices: cluster.clusterServices, - })); + const { isEnabled: isMarketplaceEnabled, flagsAreReady } = useFeatureFlag('marketplace'); const dispatch = useAppDispatch(); + const { clusterServices, isTelemetryEnabled, selectedCluster } = useAppSelector( + ({ config, cluster }) => ({ + isTelemetryEnabled: config.isTelemetryEnabled, + clusterServices: cluster.clusterServices, + selectedCluster: cluster.selectedCluster, + }), + ); const onClickLink = useCallback( (url: string, name: string) => { @@ -69,6 +72,16 @@ const Services: FunctionComponent<ServicesProps> = ({ dispatch(getMarketplaceApps()); }, [dispatch, useTelemetry, kubefirstVersion, k3dDomain, apiUrl]); + useEffect(() => { + if (!selectedCluster?.clusterName) { + router.push('/'); + } else { + dispatch(() => { + dispatch(getClusterServices({ clusterName: selectedCluster?.clusterName })); + }); + } + }, [dispatch, router, selectedCluster]); + return ( <Container> <Header> @@ -82,12 +95,14 @@ const Services: FunctionComponent<ServicesProps> = ({ {...a11yProps(SERVICES_TABS.PROVISIONED)} sx={{ textTransform: 'capitalize', mr: 3 }} /> - <Tab - color={activeTab === SERVICES_TABS.MARKETPLACE ? BISCAY : SALTBOX_BLUE} - label={<Typography variant="buttonSmall">Marketplace</Typography>} - {...a11yProps(SERVICES_TABS.MARKETPLACE)} - sx={{ textTransform: 'capitalize' }} - /> + {isMarketplaceEnabled && flagsAreReady && ( + <Tab + color={activeTab === SERVICES_TABS.MARKETPLACE ? BISCAY : SALTBOX_BLUE} + label={<Typography variant="buttonSmall">Marketplace</Typography>} + {...a11yProps(SERVICES_TABS.MARKETPLACE)} + sx={{ textTransform: 'capitalize' }} + /> + )} </Tabs> </Box> <Content> @@ -126,27 +141,12 @@ const Services: FunctionComponent<ServicesProps> = ({ </LearnMoreLink> </Typography> <Marketplace - onSubmit={(name: string) => { - setMarketplaceApp(name); + onSubmit={() => { setActiveTab(SERVICES_TABS.PROVISIONED); - open(); }} /> </TabPanel> </Content> - <Snackbar - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'right', - }} - open={isOpen} - autoHideDuration={5000} - onClose={close} - > - <Alert onClose={close} severity="success" sx={{ width: '100%' }} variant="filled"> - {`${marketplaceApp} successfully added to your cluster!`} - </Alert> - </Snackbar> </Container> ); }; diff --git a/pages/_app.tsx b/pages/_app.tsx index 982fec3d..f1371852 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -9,7 +9,7 @@ import { muiTheme } from '../theme/muiTheme'; import { theme } from '../theme'; import { wrapper } from '../redux/store'; import Header from '../containers/header'; -import Navigation from '../components/navigation'; +import Navigation from '../containers/navigation'; import Row from '../components/row'; import Column from '../components/column'; diff --git a/pages/cluster-management.tsx b/pages/cluster-management.tsx index 43b3269f..ff635c73 100644 --- a/pages/cluster-management.tsx +++ b/pages/cluster-management.tsx @@ -23,11 +23,12 @@ const ClusterManagementPage: FunctionComponent<ClusterManagementProps> = (props) }; export async function getServerSideProps() { - const { API_URL = '', USE_TELEMETRY = '' } = process.env; + const { API_URL = '', KUBEFIRST_VERSION = '', USE_TELEMETRY = '' } = process.env; return { props: { apiUrl: API_URL, + kubefirstVersion: KUBEFIRST_VERSION, useTelemetry: USE_TELEMETRY === 'true', }, }; diff --git a/pages/index.ts b/pages/index.ts index a2f29fe9..57570e7f 100644 --- a/pages/index.ts +++ b/pages/index.ts @@ -15,8 +15,8 @@ const MainPage: FunctionComponent<MainPageProps> = ({ flags }) => { const { push } = useRouter(); const dispatch = useAppDispatch(); - const { isEnabled: clusterManagementEnabled, flagsAreReady } = - useFeatureFlag('cluster-management'); + const { isEnabled: clusterProvisioningEnabled, flagsAreReady } = + useFeatureFlag('cluster-provisioning'); useEffect(() => { dispatch(setFeatureFlags(flags)); @@ -24,13 +24,11 @@ const MainPage: FunctionComponent<MainPageProps> = ({ flags }) => { useEffect(() => { if (flagsAreReady) { - if (clusterManagementEnabled) { - push('/cluster-management'); - } else { - push('/services'); + if (clusterProvisioningEnabled) { + push('/provision'); } } - }, [clusterManagementEnabled, flagsAreReady, push]); + }, [clusterProvisioningEnabled, flagsAreReady, push]); return null; }; diff --git a/pages/provision.tsx b/pages/provision.tsx index c4d27857..c83a3922 100644 --- a/pages/provision.tsx +++ b/pages/provision.tsx @@ -4,10 +4,10 @@ import { useRouter } from 'next/router'; import useFeatureFlag from '../hooks/useFeatureFlag'; import Provision, { ProvisionProps } from '../containers/provision'; -const ProvisionPage: FunctionComponent<ProvisionProps> = ({ apiUrl, useTelemetry }) => { +const ProvisionPage: FunctionComponent<ProvisionProps> = (props) => { const { push } = useRouter(); - const { flagsAreReady } = useFeatureFlag('cluster-management'); + const { flagsAreReady } = useFeatureFlag('cluster-provisioning'); useEffect(() => { if (!flagsAreReady) { @@ -19,15 +19,16 @@ const ProvisionPage: FunctionComponent<ProvisionProps> = ({ apiUrl, useTelemetry return null; } - return <Provision apiUrl={apiUrl} useTelemetry={useTelemetry} />; + return <Provision {...props} />; }; export async function getServerSideProps() { - const { API_URL = '', USE_TELEMETRY = '' } = process.env; + const { API_URL = '', KUBEFIRST_VERSION = '', USE_TELEMETRY = '' } = process.env; return { props: { apiUrl: API_URL, + kubefirstVersion: KUBEFIRST_VERSION, useTelemetry: USE_TELEMETRY === 'true', }, }; diff --git a/pages/services.tsx b/pages/services.tsx index b1a7541f..43293152 100644 --- a/pages/services.tsx +++ b/pages/services.tsx @@ -1,42 +1,16 @@ -import React, { FunctionComponent, useEffect } from 'react'; -import { useRouter } from 'next/router'; +import React, { FunctionComponent } from 'react'; -import useFeatureFlag from '../hooks/useFeatureFlag'; import Services from '../containers/services'; interface ServicesPageProps { apiUrl: string; - argoUrl: string; - argoWorkflowsUrl: string; - atlantisUrl: string; domainName: string; - githubOwner: string; - gitlabOwner: string; - gitProvider: string; k3dDomain: string; kubefirstVersion: string; useTelemetry: boolean; - vaultUrl: string; - metaphor: { - development: string; - staging: string; - production: string; - }; } -const ServicesPage: FunctionComponent<ServicesPageProps> = (props) => { - const { push } = useRouter(); - - const { flagsAreReady } = useFeatureFlag('cluster-management'); - - useEffect(() => { - if (!flagsAreReady) { - push('/'); - } - }); - - return <Services {...props} />; -}; +const ServicesPage: FunctionComponent<ServicesPageProps> = (props) => <Services {...props} />; export async function getServerSideProps() { const { @@ -45,7 +19,6 @@ export async function getServerSideProps() { K3D_DOMAIN = '', KUBEFIRST_VERSION = '', USE_TELEMETRY = '', - VAULT_URL = '', } = process.env; return { @@ -55,7 +28,6 @@ export async function getServerSideProps() { k3dDomain: K3D_DOMAIN, kubefirstVersion: KUBEFIRST_VERSION, useTelemetry: USE_TELEMETRY === 'true', - vaultUrl: VAULT_URL, }, }; } diff --git a/redux/slices/cluster.slice.ts b/redux/slices/cluster.slice.ts index 67dbe913..e0ceecb9 100644 --- a/redux/slices/cluster.slice.ts +++ b/redux/slices/cluster.slice.ts @@ -16,66 +16,7 @@ export interface ConfigState { export const initialState: ConfigState = { selectedCluster: undefined, - clusterServices: [ - { - name: 'Argo CD', - default: true, - description: - 'A GitOps oriented continuous delivery tool for managing all of our applications across our Kubernetes clusters.', - image: 'https://assets.kubefirst.com/console/argocd.svg', - links: ['https://argocd.kubesecond.net'], - status: '', - }, - { - name: 'Argo Workflows', - default: true, - description: 'The workflow engine for orchestrating parallel jobs on Kubernetes.', - image: 'https://assets.kubefirst.com/console/argocd.svg', - links: ['https://argo.kubesecond.net/workflows'], - status: '', - }, - { - name: 'Atlantis', - default: true, - description: 'Kubefirst manages Terraform workflows with Atlantis automation.', - image: 'https://assets.kubefirst.com/console/atlantis.svg', - links: ['https://atlantis.kubesecond.net'], - status: '', - }, - { - name: 'github', - default: true, - description: - 'The git repositories contain all the Infrastructure as Code and GitOps configurations.', - image: 'https://assets.kubefirst.com/console/github.svg', // or gitlab https://assets.kubefirst.com/console/gitlab.svg - links: [ - 'https://github.com/kubefirst-test/gitops', - 'https://github.com/kubefirst-test/metaphor', - ], - status: '', - }, - { - name: 'Metaphor', - default: true, - description: - "A multi-environment demonstration space for frontend application best practices that's easy to apply to other projects.", - image: 'https://assets.kubefirst.com/console/metaphor.svg', - links: [ - 'https://metaphor-development.kubesecond.net', - 'https://metaphor-staging.kubesecond.net', - 'https://metaphor-production.kubesecond.net', - ], - status: '', - }, - { - name: 'Vault', - default: true, - description: "Kubefirst's secrets manager and identity provider.", - image: 'https://assets.kubefirst.com/console/vault.svg', - links: ['https://vault.kubesecond.net'], - status: '', - }, - ], + clusterServices: [], marketplaceApps: [], }; @@ -86,17 +27,6 @@ const clusterSlice = createSlice({ setSelectedCluster: (state, { payload: cluster }: PayloadAction<Cluster>) => { state.selectedCluster = cluster; }, - addMarketplaceApp: (state, { payload: app }: PayloadAction<MarketplaceApp>) => { - console.log(app); - const { name, description, image_url } = app; - state.clusterServices.push({ - default: false, - description: description as string, - name, - image: image_url, - links: [], - }); - }, }, extraReducers: (builder) => { builder @@ -122,6 +52,6 @@ const clusterSlice = createSlice({ }, }); -export const { addMarketplaceApp, setSelectedCluster } = clusterSlice.actions; +export const { setSelectedCluster } = clusterSlice.actions; export const clusterReducer = clusterSlice.reducer; diff --git a/redux/thunks/api.thunk.ts b/redux/thunks/api.thunk.ts index 7604d8f8..a5818958 100644 --- a/redux/thunks/api.thunk.ts +++ b/redux/thunks/api.thunk.ts @@ -145,7 +145,11 @@ export const getClusterServices = createAsyncThunk< dispatch: AppDispatch; state: RootState; } ->('api/cluster/getClusterServices', async ({ apiUrl, clusterName }) => { +>('api/cluster/getClusterServices', async ({ clusterName }, { getState }) => { + const { + config: { apiUrl }, + } = getState(); + const res = await axios.get(`${apiUrl}/services/${clusterName}`); if ('error' in res) { From 2811693849d79578d71ffa1a24587504ce03e3c2 Mon Sep 17 00:00:00 2001 From: CristhianF7 <CristhianF7@gmail.com> Date: Tue, 23 May 2023 17:34:21 -0500 Subject: [PATCH 5/6] feat: error handling and git validations --- components/errorBanner/errorBanner.styled.ts | 11 +++ components/errorBanner/index.tsx | 32 ++++++-- .../clusterForms/shared/authForm/index.tsx | 52 ++---------- containers/header/index.tsx | 4 +- containers/marketplace/index.tsx | 12 ++- containers/provision/index.tsx | 18 +++-- containers/provision/provision.styled.ts | 2 + containers/service/index.tsx | 66 +++++++-------- containers/services/index.tsx | 6 +- containers/services/services.styled.ts | 2 +- containers/terminalLogs/terminalLogs.tsx | 22 +++-- hooks/useInstallation.ts | 2 +- redux/slices/git.slice.ts | 81 ++++++++++--------- redux/thunks/api.thunk.ts | 18 ++++- types/marketplace/index.ts | 3 + types/redux/index.ts | 2 +- 16 files changed, 180 insertions(+), 153 deletions(-) diff --git a/components/errorBanner/errorBanner.styled.ts b/components/errorBanner/errorBanner.styled.ts index 01f184d8..555a7905 100644 --- a/components/errorBanner/errorBanner.styled.ts +++ b/components/errorBanner/errorBanner.styled.ts @@ -9,7 +9,18 @@ export const Container = styled.div` width: calc(100% - 32px); `; +export const ErrorContainer = styled.div` + display: flex; + flex-direction: column; +`; + export const Header = styled.div` display: flex; gap: 8px; `; + +export const List = styled.ul` + padding-left: 28px; +`; + +export const ListItem = styled.li``; diff --git a/components/errorBanner/index.tsx b/components/errorBanner/index.tsx index be759822..fea07339 100644 --- a/components/errorBanner/index.tsx +++ b/components/errorBanner/index.tsx @@ -4,21 +4,41 @@ import ErrorIcon from '@mui/icons-material/Error'; import Typography from '../typography'; import { VOLCANIC_SAND } from '../../constants/colors'; -import { Container, Header } from './errorBanner.styled'; +import { Container, ErrorContainer, Header, List, ListItem } from './errorBanner.styled'; export interface ErrorBannerProps { details?: string; - text: string; + error: Array<string> | string; } -const ErrorBanner: FunctionComponent<ErrorBannerProps> = ({ text }) => { +const ErrorBanner: FunctionComponent<ErrorBannerProps> = ({ error }) => { + const isErrorArray = Array.isArray(error) && error.length > 1; return ( <Container> <Header> <ErrorIcon color="error" fontSize="small" /> - <Typography variant="body2" color={VOLCANIC_SAND}> - <div dangerouslySetInnerHTML={{ __html: text }} /> - </Typography> + <ErrorContainer> + {isErrorArray ? ( + <> + <Typography variant="body2" color={VOLCANIC_SAND}> + <strong>Error</strong> + </Typography> + <List> + {error.map((errorItem) => ( + <ListItem key={errorItem}> + <Typography variant="body2" color={VOLCANIC_SAND}> + <div dangerouslySetInnerHTML={{ __html: errorItem }} /> + </Typography> + </ListItem> + ))} + </List> + </> + ) : ( + <Typography variant="body2" color={VOLCANIC_SAND}> + <div dangerouslySetInnerHTML={{ __html: `<strong>Error </strong>${error}` }} /> + </Typography> + )} + </ErrorContainer> </Header> </Container> ); diff --git a/containers/clusterForms/shared/authForm/index.tsx b/containers/clusterForms/shared/authForm/index.tsx index 3cb67a71..37ddd720 100644 --- a/containers/clusterForms/shared/authForm/index.tsx +++ b/containers/clusterForms/shared/authForm/index.tsx @@ -6,7 +6,6 @@ import LearnMore from '../../../../components/learnMore'; import ControlledPassword from '../../../../components/controlledFields/Password'; import ControlledTextField from '../../../../components/controlledFields/TextField'; import ControlledAutocomplete from '../../../../components/controlledFields/AutoComplete'; -import { clearError, setError } from '../../../../redux/slices/installation.slice'; import { useAppDispatch, useAppSelector } from '../../../../redux/store'; import { GitProvider } from '../../../../types'; import { FormFlowProps } from '../../../../types/provision'; @@ -21,11 +20,10 @@ import { getGitlabGroups, getGitlabUser, } from '../../../../redux/thunks/git.thunk'; -import { clearGitValidationState, setToken } from '../../../../redux/slices/git.slice'; +import { setToken, clearUserError, setGitOwner } from '../../../../redux/slices/git.slice'; const AuthForm: FunctionComponent<FormFlowProps<InstallValues>> = ({ control, setValue }) => { const [isGitRequested, setIsGitRequested] = useState<boolean>(); - const [selectedGitOwner, setSelectedGitOwner] = useState<string>(); const dispatch = useAppDispatch(); const { @@ -38,10 +36,6 @@ const AuthForm: FunctionComponent<FormFlowProps<InstallValues>> = ({ control, se installationType, isTokenValid, token = '', - hasExistingRepos, - hasExistingTeams, - loadedRepositories, - loadedTeams, } = useAppSelector(({ installation, git }) => ({ currentStep: installation.installationStep, installationType: installation.installType, @@ -58,9 +52,8 @@ const AuthForm: FunctionComponent<FormFlowProps<InstallValues>> = ({ control, se const isGitHub = useMemo(() => gitProvider === GitProvider.GITHUB, [gitProvider]); const validateGitOwner = async (gitOwner: string) => { - setSelectedGitOwner(gitOwner); - await dispatch(clearError()); - await dispatch(clearGitValidationState()); + dispatch(setGitOwner(gitOwner)); + await dispatch(clearUserError()); if (gitOwner) { if (isGitHub) { await dispatch(getGitHubOrgRepositories({ token, organization: gitOwner })).unwrap(); @@ -72,8 +65,7 @@ const AuthForm: FunctionComponent<FormFlowProps<InstallValues>> = ({ control, se }; const handleGitTokenBlur = async (token: string) => { - await dispatch(clearGitValidationState()); - await dispatch(clearError()); + await dispatch(clearUserError()); await dispatch(setToken(token)); try { @@ -113,42 +105,10 @@ const AuthForm: FunctionComponent<FormFlowProps<InstallValues>> = ({ control, se ); useEffect(() => { - if (loadedRepositories && loadedTeams && selectedGitOwner) { - if (hasExistingRepos) { - dispatch( - setError({ - error: `<strong>Error </strong>${gitErrorLabel}<strong> ${selectedGitOwner} </strong> - already has a ${ - isGitHub ? 'repository' : 'project' - } named <strong>gitops</strong> or <strong>metaphor</strong>. - Please remove or rename them to continue.`, - }), - ); - } else if (hasExistingTeams) { - dispatch( - setError({ - error: `<strong>Error</strong>${gitErrorLabel} <strong> ${selectedGitOwner} </strong> - already has a team named <strong>admins</strong> or <strong>developers</strong>. - Please remove or rename them to continue.`, - }), - ); - } - } - return () => { - dispatch(clearError()); - // dispatch(clearGitValidationState()); + dispatch(clearUserError()); }; - }, [ - dispatch, - gitErrorLabel, - hasExistingRepos, - hasExistingTeams, - isGitHub, - loadedRepositories, - loadedTeams, - selectedGitOwner, - ]); + }, [dispatch, gitErrorLabel, isGitHub]); return ( <> diff --git a/containers/header/index.tsx b/containers/header/index.tsx index fbb31b91..048e3c3f 100644 --- a/containers/header/index.tsx +++ b/containers/header/index.tsx @@ -19,7 +19,9 @@ const Header: FunctionComponent = () => { })); const handleSelectCluster = (selectedClusterName: string) => { - const selectedCluster = clusters.find(({ clusterName }) => clusterName === selectedClusterName); + const selectedCluster = clusters.find( + ({ clusterName }) => clusterName.toLowerCase() === selectedClusterName.toLowerCase(), + ); if (selectedCluster) { dispatch(setSelectedCluster(selectedCluster)); diff --git a/containers/marketplace/index.tsx b/containers/marketplace/index.tsx index 76a80646..bddaf6c5 100644 --- a/containers/marketplace/index.tsx +++ b/containers/marketplace/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, FieldValues } from 'react-hook-form'; import NextLink from 'next/link'; import intersection from 'lodash/intersection'; import sortBy from 'lodash/sortBy'; @@ -24,7 +24,7 @@ const STATIC_HELP_CARD: MarketplaceApp = { image_url: 'https://assets.kubefirst.com/console/help.png', }; -const Marketplace: FunctionComponent<{ onSubmit: () => void }> = ({ onSubmit }) => { +const Marketplace: FunctionComponent = () => { const [selectedCategories, setSelectedCategories] = useState<Array<string>>([]); const [selectedApp, setSelectedApp] = useState<MarketplaceApp>(); @@ -37,6 +37,8 @@ const Marketplace: FunctionComponent<{ onSubmit: () => void }> = ({ onSubmit }) const { control, formState: { isValid }, + getValues, + reset, } = useForm(); const marketplaceApps = useAppSelector(({ cluster }) => @@ -70,13 +72,15 @@ const Marketplace: FunctionComponent<{ onSubmit: () => void }> = ({ onSubmit }) const handleAddMarketplaceApp = async (app: MarketplaceApp) => { try { + const values = getValues(); await dispatch( - installMarketplaceApp({ app, clusterName: selectedCluster?.clusterName as string }), + installMarketplaceApp({ app, clusterName: selectedCluster?.clusterName as string, values }), ); + reset(); open(); - onSubmit(); } catch (error) { //todo: handle error + console.log(error); } }; diff --git a/containers/provision/index.tsx b/containers/provision/index.tsx index 7f56e88f..dccde41e 100644 --- a/containers/provision/index.tsx +++ b/containers/provision/index.tsx @@ -35,8 +35,15 @@ const Provision: FunctionComponent<ProvisionProps> = ({ useTelemetry, }) => { const dispatch = useAppDispatch(); - const { installType, gitProvider, installationStep, values, error } = useAppSelector( - ({ installation }) => installation, + const { installType, gitProvider, installationStep, values, error, authErrors } = useAppSelector( + ({ git, installation }) => ({ + installType: installation.installType, + gitProvider: installation.gitProvider, + installationStep: installation.installationStep, + values: installation.values, + error: installation.error, + authErrors: git.errors, + }), ); const { isProvisioned } = useAppSelector(({ api }) => api); @@ -137,16 +144,16 @@ const Provision: FunctionComponent<ProvisionProps> = ({ return ( <> <FormContent hasInfo={hasInfo} isLastStep={isLastStep} isProvisionStep={isProvisionStep}> - {error && ( + {error || authErrors.length ? ( <ErrorContainer> - <ErrorBanner text={error} /> + <ErrorBanner error={error || authErrors} /> {isProvisionStep && ( <Button variant="contained" color="primary" onClick={provisionCluster}> Retry </Button> )} </ErrorContainer> - )} + ) : null} <FormFlow control={control} currentStep={installationStep} @@ -178,6 +185,7 @@ const Provision: FunctionComponent<ProvisionProps> = ({ isLastStep, isProvisionStep, error, + authErrors, provisionCluster, FormFlow, control, diff --git a/containers/provision/provision.styled.ts b/containers/provision/provision.styled.ts index b9a915f2..07a3ff60 100644 --- a/containers/provision/provision.styled.ts +++ b/containers/provision/provision.styled.ts @@ -28,6 +28,8 @@ export const FormContent = styled(FormContainer)<{ background-color: ${({ isLastStep, theme }) => (isLastStep ? 'transparent' : theme.colors.white)}; box-shadow: ${({ isProvisionStep, isLastStep }) => (isLastStep || isProvisionStep) && 'none'}; gap: 32px; + height: 500px; + overflow: auto; width: 1024px; ${({ hasInfo }) => diff --git a/containers/service/index.tsx b/containers/service/index.tsx index da6a41f1..ad600ef4 100644 --- a/containers/service/index.tsx +++ b/containers/service/index.tsx @@ -46,41 +46,41 @@ const Service: FunctionComponent<ServiceProps> = ({ links: serviceLinks, ...prop [dispatch, isSiteAvailable], ); - // useEffect(() => { - // if (availableSites.length) { - // setLinks( - // links && - // Object.keys(links).reduce( - // (previous, current) => ({ ...previous, [current]: isSiteAvailable(current) }), - // {}, - // ), - // ); - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [availableSites]); + useEffect(() => { + if (availableSites.length) { + setLinks( + links && + Object.keys(links).reduce( + (previous, current) => ({ ...previous, [current]: isSiteAvailable(current) }), + {}, + ), + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableSites]); - // useEffect(() => { - // const interval = setInterval( - // () => - // links && - // Object.keys(links).map((url) => { - // const isAvailable = links[url]; - // !isAvailable && checkSiteAvailability(url); - // }), - // 20000, - // ); - // return () => clearInterval(interval); - // }); + useEffect(() => { + const interval = setInterval( + () => + links && + Object.keys(links).map((url) => { + const isAvailable = links[url]; + !isAvailable && checkSiteAvailability(url); + }), + 20000, + ); + return () => clearInterval(interval); + }); - // useEffect(() => { - // if (!firstLoad) { - // setFirstLoad(true); - // links && - // Object.keys(links).map(async (url) => { - // await checkSiteAvailability(url); - // }); - // } - // }, [checkSiteAvailability, dispatch, firstLoad, links]); + useEffect(() => { + if (!firstLoad) { + setFirstLoad(true); + links && + Object.keys(links).map(async (url) => { + await checkSiteAvailability(url); + }); + } + }, [checkSiteAvailability, dispatch, firstLoad, links]); return <ServiceComponent {...props} links={links} />; }; diff --git a/containers/services/index.tsx b/containers/services/index.tsx index b91ed039..d989fa16 100644 --- a/containers/services/index.tsx +++ b/containers/services/index.tsx @@ -140,11 +140,7 @@ const Services: FunctionComponent<ServicesProps> = ({ Learn more </LearnMoreLink> </Typography> - <Marketplace - onSubmit={() => { - setActiveTab(SERVICES_TABS.PROVISIONED); - }} - /> + <Marketplace /> </TabPanel> </Content> </Container> diff --git a/containers/services/services.styled.ts b/containers/services/services.styled.ts index f241d3b4..cc1ce0bb 100644 --- a/containers/services/services.styled.ts +++ b/containers/services/services.styled.ts @@ -2,7 +2,7 @@ import Link from 'next/link'; import styled from 'styled-components'; export const Container = styled.div` - height: calc(100vh - 80px); + height: calc(100vh - 104px); margin: 0 auto; padding-top: 40px; width: 1192px; diff --git a/containers/terminalLogs/terminalLogs.tsx b/containers/terminalLogs/terminalLogs.tsx index 06af96ed..7a73d075 100644 --- a/containers/terminalLogs/terminalLogs.tsx +++ b/containers/terminalLogs/terminalLogs.tsx @@ -147,14 +147,20 @@ const TerminalLogs: FunctionComponent = () => { const emitter = createLogStream(`${apiUrl}/stream`); emitter.on('log', (log) => { - const [, time] = log.message.match(/time="([^"]*)"/); - const [, level] = log.message.match(/level=([^"]*)/); - const [, msg] = log.message.match(/msg="([^"]*)"/); - - const logLevel = level.replace(' msg=', '').toUpperCase(); - const logStyle = logLevel.includes('ERROR') ? '\x1b[1;31m' : '\x1b[0;34m'; - - terminal.write(`\x1b[0;37m${time} ${logStyle}${logLevel}:\x1b[1;37m ${msg} \n`); + if ( + log.message.includes('time=') && + log.message.includes('level=') && + log.message.includes('msg=') + ) { + const [, time] = log.message.match(/time="([^"]*)"/); + const [, level] = log.message.match(/level=([^"]*)/); + const [, msg] = log.message.match(/msg="([^"]*)"/); + + const logLevel = level.replace(' msg=', '').toUpperCase(); + const logStyle = logLevel.includes('ERROR') ? '\x1b[1;31m' : '\x1b[0;34m'; + + terminal.write(`\x1b[0;37m${time} ${logStyle}${logLevel}:\x1b[1;37m ${msg} \n`); + } }); emitter.on('error', () => { diff --git a/hooks/useInstallation.ts b/hooks/useInstallation.ts index 96f94559..50adc5de 100644 --- a/hooks/useInstallation.ts +++ b/hooks/useInstallation.ts @@ -167,7 +167,7 @@ const getApiKeyInfo = (type: InstallationType) => { ], }, [InstallationType.DIGITAL_OCEAN]: { - authKey: 'digitalocean_auth', + authKey: 'do_auth', fieldKeys: [ { name: 'token', diff --git a/redux/slices/git.slice.ts b/redux/slices/git.slice.ts index e405710b..2657df16 100644 --- a/redux/slices/git.slice.ts +++ b/redux/slices/git.slice.ts @@ -20,11 +20,7 @@ export interface GitState { gitlabGroups: Array<GitLabGroup>; isLoading: boolean; isTokenValid: boolean; - error: string | null; - loadedRepositories?: boolean; - loadedTeams?: boolean; - hasExistingTeams?: boolean; - hasExistingRepos?: boolean; + errors: Array<string>; token?: string; gitOwner?: string; } @@ -36,7 +32,7 @@ export const initialState: GitState = { gitlabGroups: [], isLoading: false, isTokenValid: false, - error: null, + errors: [], }; const gitSlice = createSlice({ @@ -45,13 +41,12 @@ const gitSlice = createSlice({ reducers: { setToken: (state, action) => { state.token = action.payload; - state.loadedRepositories = false; - state.loadedTeams = false; - state.hasExistingTeams = false; - state.hasExistingRepos = false; + }, + setGitOwner: (state, action) => { + state.gitOwner = action.payload; }, clearUserError: (state) => { - state.error = null; + state.errors = []; }, clearGitState: (state) => { state.githubUser = null; @@ -60,18 +55,8 @@ const gitSlice = createSlice({ state.gitlabGroups = []; state.isLoading = false; state.isTokenValid = false; - state.error = null; + state.errors = []; state.token = undefined; - state.loadedRepositories = undefined; - state.loadedTeams = undefined; - state.hasExistingTeams = undefined; - state.hasExistingRepos = undefined; - }, - clearGitValidationState: (state) => { - state.loadedRepositories = undefined; - state.loadedTeams = undefined; - state.hasExistingTeams = undefined; - state.hasExistingRepos = undefined; }, }, extraReducers: (builder) => { @@ -81,15 +66,15 @@ const gitSlice = createSlice({ state.githubUser = action.payload; state.isTokenValid = true; }) - .addCase(getGithubUser.rejected, (state, action) => { - state.error = action.error.message ?? 'Failed to get user'; - }) .addCase(getGithubUserOrganizations.pending, (state) => { state.isLoading = true; }) .addCase(getGithubUserOrganizations.rejected, (state, action) => { state.isLoading = false; - state.error = action.error.message ?? 'Failed to get users organizations'; + + if (action.error.message) { + state.errors.push('Failed to get users organizations'); + } }) .addCase(getGithubUserOrganizations.fulfilled, (state, action) => { state.githubUserOrganizations = action.payload.sort((a, b) => @@ -102,15 +87,23 @@ const gitSlice = createSlice({ const kubefirstRepos = organizationRepos.filter(({ name }) => KUBEFIRST_REPOSITORIES.includes(name), ); - state.loadedRepositories = true; - state.hasExistingRepos = kubefirstRepos.length > 0; + if (kubefirstRepos.length) { + state.errors + .push(`GitHub organization <strong>${state.gitOwner}</strong> already has repositories named + either <strong>gitops</strong> and <strong>metaphor</strong>. + Please remove or rename to continue.`); + } }) .addCase(getGitHubOrgTeams.fulfilled, (state, { payload: organizationTeams }) => { const kubefirstTeams = organizationTeams.filter(({ name }) => KUBEFIRST_TEAMS.includes(name), ); - state.loadedTeams = true; - state.hasExistingTeams = kubefirstTeams.length > 0; + + if (kubefirstTeams.length) { + state.errors.push(`GitHub organization <strong> ${state.gitOwner} </strong> + already has teams named <strong>admins</strong> or <strong>developers</strong>. + Please remove or rename them to continue.`); + } }) /* GitLab */ .addCase(getGitlabUser.fulfilled, (state, action) => { @@ -118,14 +111,18 @@ const gitSlice = createSlice({ state.isTokenValid = true; }) .addCase(getGitlabUser.rejected, (state, action) => { - state.error = action.error.message ?? 'Failed to get user'; + if (action.error.message) { + state.errors.push('Failed to get user'); + } }) .addCase(getGitlabGroups.pending, (state) => { state.isLoading = true; }) .addCase(getGitlabGroups.rejected, (state, action) => { state.isLoading = false; - state.error = action.error.message ?? 'Failed to get user groups'; + if (action.error.message) { + state.errors.push('Failed to get user groups'); + } }) .addCase(getGitlabGroups.fulfilled, (state, action) => { state.gitlabGroups = action.payload.sort((a, b) => a.name.localeCompare(b.name)); @@ -141,15 +138,23 @@ const gitSlice = createSlice({ KUBEFIRST_TEAMS.includes(name), ); - state.loadedRepositories = true; - state.loadedTeams = true; - state.hasExistingRepos = kubefirstRepos.length > 0; - state.hasExistingTeams = kubefirstTeams.length > 0; + if (kubefirstTeams.length) { + state.errors + .push(`GitLab organization <strong>${state.gitOwner}</strong> already has teams named + <strong>admins</strong> or <strong>developers</strong>. + Please remove or rename them to continue.`); + } + + if (kubefirstRepos.length) { + state.errors + .push(`GitLab organization <strong>${state.gitOwner}</strong> already has repositories named + either <strong>gitops</strong> and <strong>metaphor</strong>. + Please remove or rename to continue.`); + } }); }, }); -export const { clearGitValidationState, clearGitState, clearUserError, setToken } = - gitSlice.actions; +export const { clearGitState, clearUserError, setGitOwner, setToken } = gitSlice.actions; export const gitReducer = gitSlice.reducer; diff --git a/redux/thunks/api.thunk.ts b/redux/thunks/api.thunk.ts index a5818958..705c46a9 100644 --- a/redux/thunks/api.thunk.ts +++ b/redux/thunks/api.thunk.ts @@ -9,6 +9,7 @@ import { ClusterServices, } from '../../types/provision'; import { MarketplaceApp, MarketplaceProps } from '../../types/marketplace'; +import { FieldValues } from 'react-hook-form'; const mapClusterFromRaw = (cluster: ClusterResponse): Cluster => ({ id: cluster._id, @@ -74,8 +75,8 @@ export const createCluster = createAsyncThunk< civo_auth: { ...values?.civo_auth, }, - digitalocean_auth: { - ...values?.digitalocean_auth, + do_auth: { + ...values?.do_auth, }, vultr_auth: { ...values?.vultr_auth, @@ -185,12 +186,21 @@ export const installMarketplaceApp = createAsyncThunk< dispatch: AppDispatch; state: RootState; } ->('api/installMarketplaceApp', async ({ app, clusterName }, { getState }) => { +>('api/installMarketplaceApp', async ({ app, clusterName, values }, { getState }) => { const { config: { apiUrl }, } = getState(); - const res = await axios.post(`${apiUrl}/services/${clusterName}/${app.name}`); + const secret_keys = + values && + Object.keys(values as FieldValues).map((key) => ({ + name: key, + value: (values as FieldValues)[key], + })); + + const res = await axios.post(`${apiUrl}/services/${clusterName}/${app.name}`, { + secret_keys, + }); if ('error' in res) { throw res.error; diff --git a/types/marketplace/index.ts b/types/marketplace/index.ts index f85c0508..28718d38 100644 --- a/types/marketplace/index.ts +++ b/types/marketplace/index.ts @@ -1,3 +1,5 @@ +import { FieldValues } from 'react-hook-form'; + export interface MarketplaceApp { name: string; secret_keys?: Array<{ name: string; label: string }>; @@ -9,4 +11,5 @@ export interface MarketplaceApp { export interface MarketplaceProps { app: MarketplaceApp; clusterName: string; + values?: FieldValues; } diff --git a/types/redux/index.ts b/types/redux/index.ts index f93511f0..60e1e54c 100644 --- a/types/redux/index.ts +++ b/types/redux/index.ts @@ -17,7 +17,7 @@ export interface AuthValues { civo_auth?: { token: string; }; - digitalocean_auth?: { + do_auth?: { token: string; spaces_key: string; spaces_secret: string; From 87b306c2b4c35e6a4be9812a36263a7a9bc4cb8e Mon Sep 17 00:00:00 2001 From: CristhianF7 <CristhianF7@gmail.com> Date: Tue, 23 May 2023 18:10:04 -0500 Subject: [PATCH 6/6] fix: marketplace notification --- containers/marketplace/index.tsx | 19 +++++++++---------- redux/slices/cluster.slice.ts | 7 ++++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/containers/marketplace/index.tsx b/containers/marketplace/index.tsx index bddaf6c5..f69e6e9c 100644 --- a/containers/marketplace/index.tsx +++ b/containers/marketplace/index.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, useMemo, useState } from 'react'; -import { useForm, FieldValues } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import NextLink from 'next/link'; import intersection from 'lodash/intersection'; import sortBy from 'lodash/sortBy'; @@ -10,9 +10,9 @@ import Typography from '../../components/typography'; import MarketplaceCard from '../../components/marketplaceCard'; import MarketplaceModal from '../../components/marketplaceModal'; import useModal from '../../hooks/useModal'; -import useToggle from '../../hooks/useToggle'; import { useAppDispatch, useAppSelector } from '../../redux/store'; import { installMarketplaceApp } from '../../redux/thunks/api.thunk'; +import { setIsMarketplaceNotificationOpen } from '../../redux/slices/cluster.slice'; import { MarketplaceApp } from '../../types/marketplace'; import { VOLCANIC_SAND } from '../../constants/colors'; @@ -29,11 +29,12 @@ const Marketplace: FunctionComponent = () => { const [selectedApp, setSelectedApp] = useState<MarketplaceApp>(); const dispatch = useAppDispatch(); - const selectedCluster = useAppSelector(({ cluster }) => cluster.selectedCluster); + const { isMarketplaceNotificationOpen, selectedCluster } = useAppSelector(({ cluster }) => ({ + selectedCluster: cluster.selectedCluster, + isMarketplaceNotificationOpen: cluster.isMarketplaceNotificationOpen, + })); const { isOpen, openModal, closeModal } = useModal(); - const { isOpen: isNotificationOpen, open, close } = useToggle(); - const { control, formState: { isValid }, @@ -77,16 +78,14 @@ const Marketplace: FunctionComponent = () => { installMarketplaceApp({ app, clusterName: selectedCluster?.clusterName as string, values }), ); reset(); - open(); } catch (error) { //todo: handle error - console.log(error); } }; const handleSelectedApp = (app: MarketplaceApp) => { + setSelectedApp(app); if (app.secret_keys?.length) { - setSelectedApp(app); openModal(); } else { handleAddMarketplaceApp(app); @@ -170,9 +169,9 @@ const Marketplace: FunctionComponent = () => { vertical: 'bottom', horizontal: 'right', }} - open={isNotificationOpen} + open={isMarketplaceNotificationOpen} autoHideDuration={5000} - onClose={close} + onClose={() => dispatch(setIsMarketplaceNotificationOpen(false))} > <Alert onClose={close} severity="success" sx={{ width: '100%' }} variant="filled"> {`${selectedApp?.name} successfully added to your cluster!`} diff --git a/redux/slices/cluster.slice.ts b/redux/slices/cluster.slice.ts index e0ceecb9..bee7af6c 100644 --- a/redux/slices/cluster.slice.ts +++ b/redux/slices/cluster.slice.ts @@ -12,12 +12,14 @@ export interface ConfigState { selectedCluster?: Cluster; clusterServices: Array<ClusterServices>; marketplaceApps: Array<MarketplaceApp>; + isMarketplaceNotificationOpen: boolean; } export const initialState: ConfigState = { selectedCluster: undefined, clusterServices: [], marketplaceApps: [], + isMarketplaceNotificationOpen: false, }; const clusterSlice = createSlice({ @@ -27,6 +29,9 @@ const clusterSlice = createSlice({ setSelectedCluster: (state, { payload: cluster }: PayloadAction<Cluster>) => { state.selectedCluster = cluster; }, + setIsMarketplaceNotificationOpen: (state, { payload }: PayloadAction<boolean>) => { + state.isMarketplaceNotificationOpen = payload; + }, }, extraReducers: (builder) => { builder @@ -52,6 +57,6 @@ const clusterSlice = createSlice({ }, }); -export const { setSelectedCluster } = clusterSlice.actions; +export const { setSelectedCluster, setIsMarketplaceNotificationOpen } = clusterSlice.actions; export const clusterReducer = clusterSlice.reducer;