diff --git a/public/i18n/en/common.json b/public/i18n/en/common.json index 37696af99..3e7c7de2d 100644 --- a/public/i18n/en/common.json +++ b/public/i18n/en/common.json @@ -1,7 +1,6 @@ { "App": "App", "Apps": "Apps", - "Build": "Build", "BUTTON_NEW_RESOURCE": "Create {{model}}", "CREATE_MODEL": "Create a {{model}}", "CREATE_MODEL_FOR_TEAM": "Create a {{model}} for team {{teamName}}", @@ -78,8 +77,6 @@ "TITLE_PROJECTS": "Projects", "TITLE_PROJECT": "Project details", "DELETE_PROJECT_WARNING": "Deleting this project will permanently remove all associated collections (build, workload, service) and their data. This action cannot be undone.", - "TITLE_BUILDS": "Builds", - "TITLE_BUILD": "Build details", "Team_plural": "Teams", "Teams": "Teams", "Workload": "Workload", @@ -90,7 +87,6 @@ "User": "User", "User_plural": "Users", "Project_plural": "Projects", - "Build_plural": "Builds", "WELCOME_DASHBOARD": "Team <1>{{teamName}} dashboard", "add item": "add item", "admin": "admin", @@ -101,8 +97,12 @@ "enabled": "enabled", "help": "help", "submit": "submit", - "CodeRepository": "Code Repository", - "CodeRepository_plural": "Code Repositories", - "TITLE_CODEREPOSITORY": "Code Repository Details", - "TITLE_CODEREPOSITORIES": "Code Repositories - {{scope}}" + "Container-image": "Container Image", + "Container-image_plural": "Container Images", + "TITLE_CONTAINER_IMAGE": "Container image details", + "TITLE_CONTAINER_IMAGES": "Container images - {{scope}}", + "Code-repository": "Code Repository", + "Code-repository_plural": "Code Repositories", + "TITLE_CODE_REPOSITORY": "Code repository details", + "TITLE_CODE_REPOSITORIES": "Code repositories - {{scope}}" } diff --git a/public/logos/buildpacks_logo.svg b/public/logos/buildpacks_logo.svg new file mode 100644 index 000000000..a6b6c064f --- /dev/null +++ b/public/logos/buildpacks_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/docker_logo.svg b/public/logos/docker_logo.svg new file mode 100644 index 000000000..eba6cc41e --- /dev/null +++ b/public/logos/docker_logo.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 3c72d96c8..84c3709bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,8 @@ import cookie from 'cookie' import Backups from 'pages/Backups' import Netpols from 'pages/Netpols' import Workloads from 'pages/Workloads' -import Builds from 'pages/Builds' +import Build from 'pages/builds/create-edit' +import Builds from 'pages/builds/overview' import OtomiApp from 'pages/App' import Apps from 'pages/Apps' import Cluster from 'pages/Cluster' @@ -35,7 +36,6 @@ import { store } from 'redux/store' import { IoProvider } from 'socket.io-react-hook' import Backup from 'pages/Backup' import Netpol from 'pages/Netpol' -import Build from 'pages/Build' import LoadingScreen from 'components/LoadingScreen' import Dashboard from 'pages/Dashboard' import Users from 'pages/Users' @@ -51,9 +51,8 @@ import Policy from 'pages/Policy' import Maintenance from 'pages/Maintenance' import PrivateRoute from 'components/AuthzRoute' import Logout from 'pages/Logout' -// TODO: Uncomment the following line(s) when the new build page is ready -// import CodeRepository from 'pages/code-repositories/create-edit' -// import CodeRepositories from 'pages/code-repositories/overview' +import CodeRepository from 'pages/code-repositories/create-edit' +import CodeRepositories from 'pages/code-repositories/overview' import { HttpErrorBadRequest } from './utils/error' import { NotistackProvider, SnackbarUtilsConfigurator } from './utils/snack' @@ -88,28 +87,27 @@ function App() { - {/* TODO: Uncomment the following line(s) when the new build page is ready */} - {/* */} + /> @@ -150,7 +148,7 @@ function App() { - + @@ -164,7 +162,7 @@ function App() { - + - {/* */} - - + + diff --git a/src/components/Breadcrumb/Crumbs.tsx b/src/components/Breadcrumb/Crumbs.tsx index 07a21a54c..c039a7227 100644 --- a/src/components/Breadcrumb/Crumbs.tsx +++ b/src/components/Breadcrumb/Crumbs.tsx @@ -56,7 +56,9 @@ export const Crumbs = React.memo((props: Props) => { data-qa-link-text data-testid='link-text' > - {crumbOverrides && override ? override.label ?? crumb : crumb} + {crumbOverrides && override + ? override.label.replaceAll('-', ' ') ?? crumb.replaceAll('-', ' ') + : crumb.replaceAll('-', ' ')} / diff --git a/src/components/Breadcrumb/FinalCrumb.styles.tsx b/src/components/Breadcrumb/FinalCrumb.styles.tsx index 359bab3a8..ccd07f688 100644 --- a/src/components/Breadcrumb/FinalCrumb.styles.tsx +++ b/src/components/Breadcrumb/FinalCrumb.styles.tsx @@ -19,7 +19,7 @@ export const StyledEditableText = styled(EditableText, { export const StyledH1Header = styled(H1Header, { label: 'StyledH1Header' })(({ theme }) => ({ color: theme.palette.cl.breadCrumb.lastCrumb, - fontWeight: '400', + fontWeight: '600', fontSize: '1.125rem', textTransform: 'capitalize', [theme.breakpoints.up('lg')]: { diff --git a/src/components/Builds.tsx b/src/components/Builds.tsx index 88caf4b00..049cc68b0 100644 --- a/src/components/Builds.tsx +++ b/src/components/Builds.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import { GetTeamBuildsApiResponse } from 'redux/otomiApi' -import { Box, Tooltip } from '@mui/material' +import { Box, Tooltip, Typography } from '@mui/material' import ContentCopyIcon from '@mui/icons-material/ContentCopy' import DoneIcon from '@mui/icons-material/Done' import useStatus from 'hooks/useStatus' @@ -177,5 +177,19 @@ export default function ({ builds, teamId }: Props): React.ReactElement { if (!appsEnabled.harbor) return - return + const customButtonText = () => ( + + Add Build + + ) + + return ( + + ) } diff --git a/src/components/DeleteButton.tsx b/src/components/DeleteButton.tsx index a4a898138..1a015d298 100644 --- a/src/components/DeleteButton.tsx +++ b/src/components/DeleteButton.tsx @@ -2,8 +2,20 @@ import DeleteIcon from '@mui/icons-material/Delete' import { LoadingButton } from '@mui/lab' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' +import { darken, styled } from '@mui/material' import DeleteDialog from './DeleteDialog' +const StyledDeleteButton = styled(LoadingButton)(({ theme }) => ({ + float: 'right', + textTransform: 'capitalize', + marginLeft: theme.spacing(2), + color: 'white', + backgroundColor: theme.palette.cm.red, + '&:hover': { + backgroundColor: darken(theme.palette.cm.red as string, 0.2), + }, +})) + interface DeleteButtonProps { disabled?: boolean loading?: boolean @@ -17,18 +29,16 @@ export default function ({ loading, disabled, sx, ...other }: DeleteButtonProps) const [dialogOpen, setDialogOpen] = useState(false) const { t } = useTranslation() // END HOOKS - const onButtonClick = () => { setDialogOpen(true) } - const onDialogCancel = () => { setDialogOpen(false) } return ( <> {dialogOpen && } - } onClick={onButtonClick} @@ -37,7 +47,7 @@ export default function ({ loading, disabled, sx, ...other }: DeleteButtonProps) sx={{ ...sx }} > {t('delete')} - + ) } diff --git a/src/components/DeleteDialog.tsx b/src/components/DeleteDialog.tsx index d84d44597..31601915e 100644 --- a/src/components/DeleteDialog.tsx +++ b/src/components/DeleteDialog.tsx @@ -6,9 +6,31 @@ import DialogContentText from '@mui/material/DialogContentText' import DialogTitle from '@mui/material/DialogTitle' import TextField from '@mui/material/TextField' import React, { useState } from 'react' +import { darken, styled } from '@mui/material' import { useTranslation } from 'react-i18next' import { LoadingButton } from '@mui/lab' +const StyledDeleteButton = styled(LoadingButton)(({ theme }) => ({ + float: 'right', + textTransform: 'capitalize', + marginLeft: theme.spacing(2), + border: 'none', + color: 'white', + backgroundColor: theme.palette.cm.red, + '&:hover': { + border: 'none', + backgroundColor: darken(theme.palette.cm.red as string, 0.2), + }, + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[400], + border: 'none', + color: theme.palette.common.white, + '& .MuiSvgIcon-root': { + color: theme.palette.common.white, + }, + }, +})) + interface DeleteDialogProps { onCancel: () => void onDelete: () => void @@ -28,16 +50,18 @@ export default function ({ }: DeleteDialogProps): React.ReactElement { const [buttonDisabled, setButtonDisabled] = useState(true) const { t } = useTranslation() - // END HOOKS + const onTextFieldChange = (event) => { if (event.target.value === resourceName) setButtonDisabled(false) else setButtonDisabled(true) } + const dialogTitle = t('DELETE_RESOURCE', { resourceType, resourceName }) const dialogContent = t('DELETE_RESOURCE_CONFIRMATION', { resourceType, resourceName }) + return ( - - {dialogTitle} + + {dialogTitle} {customContent ? `${customContent} ${dialogContent}` : dialogContent} Cancel - Delete - + ) diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx new file mode 100644 index 000000000..aee00f982 --- /dev/null +++ b/src/components/Divider.tsx @@ -0,0 +1,22 @@ +import _Divider, { DividerProps as _DividerProps } from '@mui/material/Divider' +import { styled } from '@mui/material/styles' +import * as React from 'react' + +import { omittedProps } from '../utils/omittedProps' + +export interface DividerProps extends _DividerProps { + spacingBottom?: number + spacingTop?: number +} + +export function Divider(props: DividerProps) { + return +} + +const StyledDivider = styled(_Divider, { + label: 'StyledDivider', + shouldForwardProp: omittedProps(['spacingTop', 'spacingBottom']), +})(({ theme, ...props }) => ({ + marginBottom: props.spacingBottom, + marginTop: props.spacingTop, +})) diff --git a/src/components/ImgButtonGroup.tsx b/src/components/ImgButtonGroup.tsx index 90d4f954d..6c5628d1d 100644 --- a/src/components/ImgButtonGroup.tsx +++ b/src/components/ImgButtonGroup.tsx @@ -29,15 +29,16 @@ const StyledTypography = styled(Typography)<{ selected: boolean }>(({ theme, sel })) interface ImgButtonGroupProps { - title: string + title?: string name: string control: any value: string options: { value: string; label: string; imgSrc: string }[] onChange?: (value: string) => void + disabled?: boolean } -function ImgButtonGroup({ title, name, control, value, options, onChange }: ImgButtonGroupProps) { +function ImgButtonGroup({ title, name, control, value, options, onChange, disabled = false }: ImgButtonGroupProps) { return ( ( - - - {title} - - + {title && ( + + + {title} + + + )} {options.map((option) => ( { + if (disabled) return field.onChange(option.value) onChange?.(option.value) }} diff --git a/src/components/NavConfig.tsx b/src/components/NavConfig.tsx index d9dac51d5..157e705cd 100644 --- a/src/components/NavConfig.tsx +++ b/src/components/NavConfig.tsx @@ -29,9 +29,8 @@ export default function NavConfig() { { title: 'Teams', path: '/teams', icon: getIcon('teams_icon.svg') }, { title: 'User Management', path: '/users', icon: getIcon('users_icon.svg'), hidden: hasExternalIDP }, { title: 'Projects', path: '/projects', icon: getIcon('projects_icon.svg') }, - // TODO: Uncomment the following line(s) when the new build page is ready - // { title: 'Code Repositories', path: '/coderepositories', icon: getIcon('coderepositories_icon.svg') }, - { title: 'Builds', path: '/builds', icon: getIcon('builds_icon.svg') }, + { title: 'Code Repositories', path: '/code-repositories', icon: getIcon('coderepositories_icon.svg') }, + { title: 'Container Images', path: '/container-images', icon: getIcon('builds_icon.svg') }, { title: 'Workloads', path: '/workloads', icon: getIcon('workloads_icon.svg') }, { title: 'Network Policies', path: '/netpols', icon: getIcon('policies_icon.svg') }, { title: 'Services', path: '/services', icon: getIcon('services_icon.svg') }, @@ -59,13 +58,12 @@ export default function NavConfig() { path: `/teams/${oboTeamId}/projects`, icon: getIcon('projects_icon.svg'), }, - // TODO: Uncomment the following line(s) when the new build page is ready - // { - // title: 'Code Repositories', - // path: `/teams/${oboTeamId}/coderepositories`, - // icon: getIcon('coderepositories_icon.svg'), - // }, - { title: 'Builds', path: `/teams/${oboTeamId}/builds`, icon: getIcon('builds_icon.svg') }, + { + title: 'Code Repositories', + path: `/teams/${oboTeamId}/code-repositories`, + icon: getIcon('coderepositories_icon.svg'), + }, + { title: 'Container Images', path: `/teams/${oboTeamId}/container-images`, icon: getIcon('builds_icon.svg') }, { title: 'Sealed Secrets', path: `/teams/${oboTeamId}/sealed-secrets`, icon: getIcon('shield_lock_icon.svg') }, { title: 'Workloads', path: `/teams/${oboTeamId}/workloads/`, icon: getIcon('workloads_icon.svg') }, { title: 'Network Policies', path: `/teams/${oboTeamId}/netpols/`, icon: getIcon('policies_icon.svg') }, diff --git a/src/components/SealedSecret.tsx b/src/components/SealedSecret.tsx index 677565ecb..46cba7a65 100644 --- a/src/components/SealedSecret.tsx +++ b/src/components/SealedSecret.tsx @@ -108,7 +108,7 @@ export default function ({ secret, teamId, isCoderepository, ...other }: Props): }, [secret]) useEffect(() => { - if (data?.name) return + if (secret?.name) return setData((prev: any) => { if (!prev?.type) return prev const data = { ...prev } diff --git a/src/components/forms/Autocomplete.tsx b/src/components/forms/Autocomplete.tsx new file mode 100644 index 000000000..64cd556c4 --- /dev/null +++ b/src/components/forms/Autocomplete.tsx @@ -0,0 +1,124 @@ +import MuiAutocomplete from '@mui/material/Autocomplete' +import React, { JSX, useState } from 'react' + +import type { AutocompleteProps, AutocompleteRenderInputParams } from '@mui/material/Autocomplete' +import ArrowDropDownIcon from '@mui/icons-material/ExpandMore' +import { TextField } from './TextField' + +import type { TextFieldProps } from './TextField' + +export interface EnhancedAutocompleteProps< + T extends { label: string }, + Multiple extends boolean | undefined = undefined, + DisableClearable extends boolean | undefined = undefined, + FreeSolo extends boolean | undefined = undefined, +> extends Omit, 'renderInput'> { + /** Provides a hint with error styling to assist users. */ + errorText?: string + /** Provides a hint with normal styling to assist users. */ + helperText?: TextFieldProps['helperText'] + /** A required label for the Autocomplete to ensure accessibility. */ + label: string + /** Removes the top margin from the input label, if desired. */ + noMarginTop?: boolean + /** Element to show when the Autocomplete search yields no results. */ + noOptionsText?: JSX.Element | string + placeholder?: string + renderInput?: (_params: AutocompleteRenderInputParams) => React.ReactNode + /** Label for the "select all" option. */ + selectAllLabel?: string + textFieldProps?: Partial +} + +/** + * An Autocomplete component that provides a user-friendly select input + * allowing selection between options. + * + * @example + * console.log(selected)} + * options={[ + * { + * label: 'Apple', + * value: 'apple', + * } + * ]} + * /> + */ +export function Autocomplete< + T extends { label: string }, + Multiple extends boolean | undefined = undefined, + DisableClearable extends boolean | undefined = undefined, + FreeSolo extends boolean | undefined = undefined, +>(props: EnhancedAutocompleteProps) { + const { + clearOnBlur, + defaultValue, + disablePortal = true, + errorText = '', + helperText, + label, + limitTags = 2, + loading = false, + loadingText, + noOptionsText, + onBlur, + options, + placeholder, + renderInput, + textFieldProps, + value, + onChange, + ...rest + } = props + const [inPlaceholder, setInPlaceholder] = useState('') + + return ( + ( + + )) + } + clearOnBlur={clearOnBlur} + data-qa-autocomplete={label} + defaultValue={defaultValue} + disablePortal={disablePortal} + limitTags={limitTags} + loading={loading} + loadingText={loadingText || 'Loading...'} + noOptionsText={noOptionsText || You have no options to choose from} + onBlur={onBlur} + onOpen={() => setInPlaceholder('Search')} + onClose={() => setInPlaceholder(placeholder || '')} + popupIcon={} + value={value} + {...rest} + onChange={onChange} + /> + ) +} diff --git a/src/components/forms/FormRow.tsx b/src/components/forms/FormRow.tsx index 687502328..d459a61c1 100644 --- a/src/components/forms/FormRow.tsx +++ b/src/components/forms/FormRow.tsx @@ -1,13 +1,14 @@ import React from 'react' -import { Box } from '@mui/material' +import { Box, SxProps } from '@mui/material' interface FormRowProps { children: React.ReactNode spacing?: number // spacing in pixels + sx?: SxProps } export default function FormRow(props: FormRowProps) { - const { children, spacing = 0 } = props + const { children, spacing = 0, sx } = props return ( *:not(:last-child)': { marginRight: `${spacing}px`, }, + ...sx, }} > {React.Children.map(children, (child) => diff --git a/src/components/forms/KeyValue.tsx b/src/components/forms/KeyValue.tsx new file mode 100644 index 000000000..3f2db4ded --- /dev/null +++ b/src/components/forms/KeyValue.tsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react' +import { Box, Button, IconButton, StandardTextFieldProps } from '@mui/material' +import { TextField } from 'components/forms/TextField' +import { makeStyles } from 'tss-react/mui' +import { Theme } from '@mui/material/styles' +import font from 'theme/font' +import { Add, Clear } from '@mui/icons-material' +import { Typography } from 'components/Typography' +import { InputLabel } from 'components/InputLabel' +import { useFieldArray, useFormContext } from 'react-hook-form' +import FormRow from './FormRow' +import { FormHelperText } from '../FormHelperText' + +const useStyles = makeStyles()((theme: Theme) => ({ + container: { + padding: '16px', + backgroundColor: '#424242', + borderRadius: '8px', + }, + inputLabel: { + color: theme.palette.cl.text.title, + fontFamily: font.bold, + fontWeight: 700, + fontSize: '1rem', + lineHeight: '1.5rem', + }, + itemRow: { + marginBottom: '20px', + display: 'flex', + alignItems: 'center', + }, + addItemButton: { + marginLeft: '-2px', + display: 'flex', + alignItems: 'center', + textTransform: 'none', + }, + errorText: { + alignItems: 'center', + color: '#d63c42', + display: 'flex', + left: 5, + top: 42, + width: '100%', + }, + helperTextTop: { + color: theme.palette.cl.text.subTitle, + marginTop: 0, + }, + label: { + fontFamily: 'sans-serif', + }, +})) + +interface TextFieldPropsOverrides extends StandardTextFieldProps { + label: string +} + +interface KeyValueProps { + title: string + subTitle?: string + keyLabel: string + keyValue?: string + keyDisabled?: boolean + helperText?: string + helperTextPosition?: 'bottom' | 'top' + showLabel?: boolean + valueLabel: string + valueDisabled?: boolean + addLabel?: string + label?: string + error?: boolean + name: string + keySize?: 'small' | 'medium' | 'large' + valueSize?: 'small' | 'medium' | 'large' + onlyValue?: boolean + errorText?: string +} + +export default function KeyValue(props: KeyValueProps) { + const { classes, cx } = useStyles() + const { control, register } = useFormContext() + + const { + title, + subTitle, + keyLabel, + valueLabel, + addLabel, + name, + label, + helperText, + helperTextPosition, + onlyValue, + keyValue, + keySize = 'medium', + valueSize = 'medium', + error, + errorText, + keyDisabled = false, + showLabel = true, + valueDisabled = false, + } = props + + const [items, setItems] = useState([{ [keyLabel.toLowerCase()]: '', [valueLabel.toLowerCase()]: '' }]) + + const { fields, append, remove } = useFieldArray({ + control, + name, + }) + + const handleAddItem = () => { + append(onlyValue ? '' : { [keyLabel.toLowerCase()]: '', [valueLabel.toLowerCase()]: '' }) + } + + const errorScrollClassName = 'error-for-scroll' + return ( + + {title} + {subTitle && {subTitle}} + + {fields.map((item, index) => ( + + + + + + {addLabel && ( + remove(index)}> + + + )} + + ))} + {addLabel && ( + + )} + {errorText && ( + + {errorText} + + )} + {helperText && (helperTextPosition === 'bottom' || !helperTextPosition) && ( + {helperText} + )} + + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 0cfb2cd7c..0c142902b 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -1,6 +1,8 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown' +import ArrowDropUpIcon from '@mui/icons-material/ExpandLess' +import ArrowDropDownIcon from '@mui/icons-material/ExpandMore' import { Theme, useTheme } from '@mui/material/styles' -import { TextField as MuiTextField, StandardTextFieldProps } from '@mui/material' +import { IconButton, TextField as MuiTextField, StandardTextFieldProps } from '@mui/material' import { clamp } from 'ramda' import * as React from 'react' import { makeStyles } from 'tss-react/mui' @@ -13,6 +15,7 @@ import { FormHelperText } from '../FormHelperText' import { InputAdornment } from '../InputAdornment' import { InputLabel } from '../InputLabel' import { TooltipProps } from '../Tooltip' +import { Typography } from '../Typography' const useStyles = makeStyles()((theme: Theme) => ({ absolute: { @@ -114,6 +117,24 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })) +/** + * Extend your existing TextField props with optional number-spinner props. + */ +interface SpinnerProps { + /** + * Function to call when the up arrow is clicked. + */ + onIncrement?: () => void + /** + * Function to call when the down arrow is clicked. + */ + onDecrement?: () => void + /** + * Optional suffix symbol (e.g. '%') to show before the spinner. + */ + suffixSymbol?: string +} + interface BaseProps { /** * className to apply to the underlying TextField component @@ -150,6 +171,10 @@ interface BaseProps { * @default false */ hasAbsoluteError?: boolean + /** + * Adds optional helper text to the Textfield + */ + helperText?: string /** * Placement of the `helperText` * @default bottom @@ -215,57 +240,14 @@ interface InputToolTipProps { } interface TextFieldPropsOverrides extends StandardTextFieldProps { - // We override this prop to make it required label: string } -export type TextFieldProps = BaseProps & TextFieldPropsOverrides & LabelToolTipProps & InputToolTipProps - /** -### Overview - -Text fields allow users to enter text into a UI. - -### Usage - -- Input fields should be sized to the data being entered (ex. the entry for a street address should be wider than a zip code). -- Ensure that the field can accommodate at least one more character than the maximum number to be entered. - -### Rules - -- Every input must have a descriptive label of what that field is. -- Required fields should include the text “(Required)” as part of the input label. -- If most fields are required, then indicate the optional fields with the text “(Optional)” instead. -- Avoid long labels; use succinct, short and descriptive labels (a word or two) so users can quickly scan your form.
Label text shouldn’t take up multiple lines. -- Placeholder text is the text that users see before they interact with a field. It should be a useful guide to the input type and format
Don’t make the user guess what format they should use for the field. Tell this information up front. - -### Best Practices - -- A single column form with input fields stacked sequentially is the easiest to understand and leads to the highest success rate. Input fields in multiple columns can be overlooked or add unnecessary visual clutter. -- Grouping related inputs (ex. mailing address) under a subhead or rule can add meaning and make the form feel more manageable. -- Avoid breaking a single form into multiple “papers” unless those sections are truly independent of each other. -- Consider sizing the input field to the data being entered (ex. the field for a street address should be wider than the field for a zip code). Balance this goal with the visual benefits of fields of the same length. A somewhat outsized input that aligns with the fields above and below it might be the best choice. - -## Textfield errors - -### Overview - -Error messages are an indicator of system status: they let users know that a hurdle was encountered and give solutions to fix it. Users should not have to memorize instructions in order to fix the error. - -### Main Principles - -- Should be easy to notice and understand. -- Should give solutions to how to fix the error. -- Users should not have to memorize instructions in order to fix the error. -- Long error messages for short text fields can extend beyond the text field. -- When the user has finished filling in a field and clicks the submit button, an indicator should appear if the field contains an error. Use red to differentiate error fields from normal ones. - -## Number Text Fields - -### Overview - -Number Text Fields are used for strictly numerical input + * Extend your TextFieldProps to include spinner props. */ +export type TextFieldProps = BaseProps & TextFieldPropsOverrides & LabelToolTipProps & InputToolTipProps & SpinnerProps + export const TextField = React.forwardRef(function TextField(props: TextFieldProps, ref) { const { classes, cx } = useStyles() @@ -287,7 +269,6 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro helperTextPosition, hideLabel, inputId, - inputProps, label, labelTooltipText, loading, @@ -306,6 +287,9 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro type, value, width = 'medium', + onIncrement, + onDecrement, + suffixSymbol, ...textFieldProps } = props @@ -374,7 +358,6 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro ...e, target: e.target.cloneNode(), } as React.ChangeEvent - clonedEvent.target.value = `${cleanedValue}` onChange(clonedEvent) } else onChange(e) @@ -382,11 +365,77 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro } let errorScrollClassName = '' - if (errorText) errorScrollClassName = errorGroup ? `error-for-scroll-${errorGroup}` : `error-for-scroll` const validInputId = inputId || (label ? convertToKebabCase(`${label}`) : undefined) + // Default handlers if spinner functions aren’t provided. + const handleDefaultIncrement = () => { + const current = typeof _value === 'number' ? _value : parseFloat(_value) || 0 + const newVal = current + 1 + setValue(newVal) + if (onChange) { + const event = { + target: { value: newVal.toString() }, + } as React.ChangeEvent + onChange(event) + } + } + + const handleDefaultDecrement = () => { + const current = typeof _value === 'number' ? _value : parseFloat(_value) || 0 + const newVal = current - 1 + setValue(newVal) + if (onChange) { + const event = { + target: { value: newVal.toString() }, + } as React.ChangeEvent + onChange(event) + } + } + + let finalEndAdornment + + if (loading) { + finalEndAdornment = ( + + + + ) + } else if (type === 'number') { + finalEndAdornment = ( + + {suffixSymbol && {suffixSymbol}} + + + + + + + + + + ) + } else finalEndAdornment = InputProps?.endAdornment + return ( - - - ), - ...InputProps, }} SelectProps={{ IconComponent: KeyboardArrowDown, @@ -490,18 +536,10 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro error={!!error || !!errorText} sx={{ width: widthMap[width] }} helperText='' - /** - * Set _helperText_ and _label_ to no value because we want to - * have the ability to put the helper text under the label at the top. - */ label='' onBlur={handleBlur} onChange={handleChange} type={type} - /* - * Let us explicitly pass an empty string to the input - * See UserDefinedFieldsPanel.tsx for a verbose explanation why. - */ value={_value} variant='standard' > @@ -517,12 +555,15 @@ export const TextField = React.forwardRef(function TextField(props: TextFieldPro })} data-qa-textfield-error-text={label} role='alert' + sx={{ width: widthMap[width] }} > {errorText} )} {helperText && (helperTextPosition === 'bottom' || !helperTextPosition) && ( - {helperText} + + {helperText} + )} ) diff --git a/src/pages/builds/create-edit/create-edit.validator.ts b/src/pages/builds/create-edit/create-edit.validator.ts new file mode 100644 index 000000000..62df30c70 --- /dev/null +++ b/src/pages/builds/create-edit/create-edit.validator.ts @@ -0,0 +1,86 @@ +import { array, boolean, mixed, object, string } from 'yup' + +const envVarSchema = object({ + name: string() + .required('Environment variable name is required') + .matches(/^[a-zA-Z_][a-zA-Z0-9_]*$/, 'Invalid environment variable name'), + value: string().required('Environment variable value is required'), +}) + +const commonModeSchema = object({ + repoUrl: string().required('Repository URL is required').url('Invalid repository URL'), + path: string().optional(), + revision: string().optional(), + envVars: array().of(envVarSchema).optional(), +}) + +export const buildApiResponseSchema = object({ + id: string().optional(), + teamId: string().optional(), + name: string() + .optional() + .matches( + /^[a-z]([-a-z0-9]*[a-z0-9])+$/, + 'Invalid format, must start with a lowercase letter, contain only lowercase letters, numbers, or hyphens, and end with a letter or number.', + ), + imageName: string() + .required('Image name is required') + .matches(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/, 'Image name can only contain lowercase letters, numbers, and hyphens.') + .test( + 'name-matches-build-name', + 'Invalid container image name, the combined image name and tag must not exceed 128 characters.', + function (imageName) { + const { tag } = this.parent + const expectedBuildName = `${imageName}-${tag}` + return expectedBuildName.length <= 128 + }, + ) + .test( + 'is-unique', + 'Container image name already exists, the combined image name and tag must be unique.', + function (value) { + const { buildNames, validateOnSubmit } = this.options.context || {} + // Only validate uniqueness if `validateOnSubmit` is true + if (!validateOnSubmit) return true + const { tag } = this.parent + const expectedBuildName = `${value}-${tag}` + return !buildNames.some((name) => name === expectedBuildName) + }, + ), + tag: string() + .required('Tag is required') + // https://pkg.go.dev/github.com/distribution/reference#pkg-overview + .matches( + /^[\w][\w.-]{0,127}$/, + 'Tag must start with a letter, digit, or underscore, and can include dots, hyphens, underscores.', + ) + .test('tag-matches-build-name', '', function (tag) { + const { imageName } = this.parent + const expectedBuildName = `${imageName}-${tag}` + return expectedBuildName.length <= 128 + }) + .test('is-unique', '', function (value) { + const { buildNames, validateOnSubmit } = this.options.context || {} + // Only validate uniqueness if `validateOnSubmit` is true + if (!validateOnSubmit) return true + const { imageName } = this.parent + const expectedBuildName = `${imageName}-${value}` + return !buildNames.some((name) => name === expectedBuildName) + }), + mode: object({ + type: string().required('Mode type is required').oneOf(['docker', 'buildpacks'], 'Invalid mode type'), + docker: mixed().when('type', { + is: 'docker', + then: commonModeSchema.required('Docker configuration is required'), + }), + buildpacks: mixed().when('type', { + is: 'buildpacks', + then: commonModeSchema.required('Buildpacks configuration is required'), + }), + }).required('Mode configuration is required'), + codeRepoName: string().optional(), + externalRepo: boolean().optional(), + secretName: string().optional(), + trigger: boolean().optional(), + scanSource: boolean().optional(), +}) diff --git a/src/pages/builds/create-edit/index.tsx b/src/pages/builds/create-edit/index.tsx new file mode 100644 index 000000000..240c36118 --- /dev/null +++ b/src/pages/builds/create-edit/index.tsx @@ -0,0 +1,359 @@ +import { Box, Grid, Typography, useTheme } from '@mui/material' +import { TextField } from 'components/forms/TextField' +import PaperLayout from 'layouts/Paper' +import React, { useEffect, useState } from 'react' +import { Redirect, RouteComponentProps } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useAppSelector } from 'redux/hooks' +import { + CreateBuildApiResponse, + GetCodeRepoApiResponse, + useCreateBuildMutation, + useDeleteBuildMutation, + useEditBuildMutation, + useGetBuildQuery, + useGetRepoBranchesQuery, + useGetTeamBuildsQuery, + useGetTeamCodeReposQuery, +} from 'redux/otomiApi' +import { cloneDeep } from 'lodash' +import { LandingHeader } from 'components/LandingHeader' +import { FormProvider, Resolver, useForm } from 'react-hook-form' +import FormRow from 'components/forms/FormRow' +import DeleteButton from 'components/DeleteButton' +import { yupResolver } from '@hookform/resolvers/yup' +import Section from 'components/Section' +import ImgButtonGroup from 'components/ImgButtonGroup' +import { Divider } from 'components/Divider' +import KeyValue from 'components/forms/KeyValue' +import ControlledCheckbox from 'components/forms/ControlledCheckbox' +import { Autocomplete } from 'components/forms/Autocomplete' +import { useSession } from 'providers/Session' +import { LoadingButton } from '@mui/lab' +import { buildApiResponseSchema } from './create-edit.validator' + +const getBuildName = (name: string, tag: string): string => { + return `${name}-${tag}` + .toLowerCase() + .replace(/[^a-z0-9-]/gi, '-') // Replace invalid characters with hyphens + .replace(/-+/g, '-') // Replace multiple consecutive hyphens with a single hyphen + .replace(/^-|-$/g, '') // Remove leading or trailing hyphens +} + +interface Params { + teamId: string + buildName?: string +} + +export default function CreateEditBuilds({ + match: { + params: { teamId, buildName }, + }, +}: RouteComponentProps): React.ReactElement { + // state + const { t } = useTranslation() + const theme = useTheme() + const [data, setData]: any = useState() + const [repoName, setRepoName] = useState('') + const [gitService, setGitService] = useState('') + + const { + settings: { + cluster: { domainSuffix }, + }, + } = useSession() + + const options = [ + { + value: 'docker', + label: 'Docker', + imgSrc: '/logos/docker_logo.svg', + }, + { + value: 'buildpacks', + label: 'BuildPacks', + imgSrc: '/logos/buildpacks_logo.svg', + }, + ] + + const [create, { isLoading: isLoadingCreate, isSuccess: isSuccessCreate }] = useCreateBuildMutation() + const [update, { isLoading: isLoadingUpdate, isSuccess: isSuccessUpdate }] = useEditBuildMutation() + const [del, { isLoading: isLoadingDelete, isSuccess: isSuccessDelete }] = useDeleteBuildMutation() + const { + data: buildData, + isLoading, + isFetching, + isError, + refetch, + } = useGetBuildQuery({ teamId, buildName }, { skip: !buildName }) + const { data: teamBuilds } = useGetTeamBuildsQuery({ teamId }, { skip: !teamId }) + const { data: codeRepos, isLoading: isLoadingCodeRepos } = useGetTeamCodeReposQuery({ teamId }) + const { data: repoBranches, isLoading: isLoadingRepoBranches } = useGetRepoBranchesQuery( + { codeRepoName: repoName, teamId }, + { skip: !repoName }, + ) + + const isDirty = useAppSelector(({ global: { isDirty } }) => isDirty) + useEffect(() => { + if (isDirty !== false) return + if (!isFetching) refetch() + }, [isDirty]) + + useEffect(() => { + if (buildName) setData(buildData) + }, [buildData, buildName]) + // END HOOKS + + // form state + const defaultValues = { + mode: { type: 'docker', docker: { path: './Dockerfile', envVars: [] } }, + externalRepo: false, + } + const methods = useForm({ + resolver: yupResolver(buildApiResponseSchema) as Resolver, + defaultValues: data || defaultValues, + context: { buildNames: teamBuilds?.map((build) => build.name), validateOnSubmit: !buildName }, + }) + const { + control, + register, + reset, + handleSubmit, + watch, + formState: { errors }, + setValue, + unregister, + } = methods + + useEffect(() => { + if (!buildData) return + reset(buildData) + const modeType = watch('mode.type') + const repoUrl = watch(`mode.${modeType}.repoUrl`) + const codeRepo = codeRepos?.find((codeRepo) => codeRepo.repositoryUrl === repoUrl) + setRepoName(codeRepo?.name || '') + setGitService(codeRepo?.gitService || '') + }, [buildData, setValue]) + + const mutating = isLoadingCreate || isLoadingUpdate || isLoadingDelete || isLoadingCodeRepos + if (!mutating && (isSuccessUpdate || isSuccessDelete)) return + if (!mutating && isSuccessCreate) return + + const onSubmit = () => { + const body = cloneDeep(watch()) + if (buildName) { + body.name = buildData.name + update({ teamId, buildName, body }) + } else { + body.name = getBuildName(body.imageName, body.tag) + create({ teamId, body }) + } + } + + if (isLoading || isError || (buildName && !watch('name'))) + return + + return ( + + + + +
+
+ Select build task + + { + const isDocker = selectedType === 'docker' + const previousType = isDocker ? 'buildpacks' : 'docker' + const nextMode = { + ...watch(`mode.${previousType}`), + path: isDocker ? './Dockerfile' : '', + } + + setValue(`mode.${selectedType as 'docker' | 'buildpacks'}`, nextMode) + unregister(`mode.${previousType}`) + }} + /> + + + Select code repository + + { + return { label: codeRepo.name, codeRepo } + })} + placeholder='Select a repository' + {...register(`mode.${watch('mode.type')}.repoUrl`)} + value={ + codeRepos?.find( + (codeRepo) => codeRepo.repositoryUrl === watch(`mode.${watch('mode.type')}.repoUrl`), + )?.name || watch(`mode.${watch('mode.type')}.repoUrl`) + } + onChange={(e, value: { label: string; codeRepo: GetCodeRepoApiResponse }) => { + const label: string = value?.label || '' + const codeRepo: GetCodeRepoApiResponse = value?.codeRepo || ({} as GetCodeRepoApiResponse) + const { repositoryUrl, gitService, private: isPrivate, secret } = codeRepo + if (!buildName) setValue('imageName', label) + setValue(`mode.${watch('mode.type')}.repoUrl`, repositoryUrl) + setValue(`mode.${watch('mode.type')}.revision`, undefined) + setValue('externalRepo', gitService !== 'gitea') + setGitService(gitService) + setRepoName(label) + if (isPrivate) setValue('secretName', secret) + else unregister('secretName') + }} + errorText={errors?.mode?.[`${watch('mode.type')}`]?.repoUrl?.message?.toString()} + disabled={!!buildName} + /> + + { + return { label: branch } + })} + placeholder='Select a reference' + {...register(`mode.${watch('mode.type')}.revision`)} + value={watch(`mode.${watch('mode.type')}.revision`) || ''} + onChange={(e, value: { label: string }) => { + const label: string = value?.label || '' + setValue(`mode.${watch('mode.type')}.revision`, label) + if (!buildName) setValue('tag', label) + }} + errorText={errors?.mode?.[`${watch('mode.type')}`]?.revision?.message?.toString()} + /> + + { + const value = e.target.value + setValue(`mode.${watch('mode.type')}.path`, value) + }} + error={!!errors[`mode.${watch('mode.type')}.path`]} + helperText={ + errors?.[`mode.${watch('mode.type')}.path`]?.message?.toString() || + 'Relative sub-path to a source code directory' + } + /> + + + + + Image name and tag + + { + const value = e.target.value + setValue('imageName', value) + }} + error={!!errors.imageName} + helperText={errors?.imageName?.message?.toString()} + disabled={!!buildName} + /> + { + const value = e.target.value + setValue('tag', value) + }} + error={!!errors.tag} + helperText={errors?.tag?.message?.toString()} + disabled={!!buildName} + /> + + + {buildName + ? `Full repository name: harbor.${domainSuffix}/team-${teamId}/${buildData.imageName}:${buildData.tag}` + : `Full repository name: harbor.${domainSuffix}/team-${teamId}/${watch('imageName') || '___'}:${ + watch('tag') || '___' + }`} + + + + + + + + + Extra options + + {gitService === 'gitea' && ( + + )} + + +
+ {buildName && ( + del({ teamId, buildName })} + resourceName={watch('name')} + resourceType='build' + data-cy='button-delete-build' + sx={{ float: 'right', textTransform: 'capitalize', ml: 2 }} + loading={isLoadingDelete} + disabled={isLoadingDelete || isLoadingCreate || isLoadingUpdate} + /> + )} + + {buildName ? 'Edit Container Image' : 'Create Container Image'} + + +
+
+
+ ) +} diff --git a/src/pages/builds/overview/index.tsx b/src/pages/builds/overview/index.tsx new file mode 100644 index 000000000..174896f1d --- /dev/null +++ b/src/pages/builds/overview/index.tsx @@ -0,0 +1,219 @@ +import { skipToken } from '@reduxjs/toolkit/query/react' +import { HeadCell } from 'components/EnhancedTable' +import InformationBanner from 'components/InformationBanner' +import ListTable from 'components/ListTable' +import { getStatus } from 'components/Workloads' +import useStatus from 'hooks/useStatus' +import PaperLayout from 'layouts/Paper' +import { useSession } from 'providers/Session' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, RouteComponentProps } from 'react-router-dom' +import { useAppSelector } from 'redux/hooks' +import { useGetAllBuildsQuery, useGetTeamBuildsQuery } from 'redux/otomiApi' +import { getRole } from 'utils/data' +import { Box, Tooltip } from '@mui/material' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import DoneIcon from '@mui/icons-material/Done' +import RLink from '../../../components/Link' + +interface Row { + teamId: string + tag: string + id: string + name: string + imageName: string + trigger: boolean + mode: { type: string } +} + +const getBuildLink = (row: Row) => { + const path = `/teams/${row.teamId}/container-images/${encodeURIComponent(row.name)}` + return ( + + {row.name} + + ) +} + +const getTektonTaskRunLink = (row: Row, domainSuffix: string) => { + const path = `/#/namespaces/team-${row.teamId}/pipelineruns/${row.mode.type}-build-${row.name}` + const triggerPath = `/#/namespaces/team-${row.teamId}/pipelineruns/` + const host = `https://tekton-${row.teamId}.${domainSuffix}` + const externalUrl = `${host}/${path}` + const externalUrlTrigger = `${host}/${triggerPath}` + + if (row.trigger) { + return ( + + PipelineRun + + ) + } + + return ( + + PipelineRun + + ) +} + +function WebhookUrlRenderer({ row }: { row: Row }) { + const [copied, setCopied] = useState(false) + const webhookUrl = `http://el-gitea-webhook-${row.name}.team-${row.teamId}.svc.cluster.local:8080` + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(webhookUrl) + setCopied(true) + setTimeout(() => { + setCopied(false) + }, 3000) + } + return ( + + + + {!copied ? ( + + + + ) : ( + + + + )} + + + ) +} + +function RepositoryRenderer({ row, domainSuffix }: { row: Row; domainSuffix: string }) { + const [copied, setCopied] = useState(false) + const repository = `harbor.${domainSuffix}/team-${row.teamId}/${row.imageName}` + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(repository) + setCopied(true) + setTimeout(() => { + setCopied(false) + }, 3000) + } + return ( + + + + {!copied ? ( + + + + ) : ( + + + + )} + + + ) +} + +interface Params { + teamId?: string +} + +export default function BuildsOverview({ + match: { + params: { teamId }, + }, +}: RouteComponentProps): React.ReactElement { + const { t } = useTranslation() + const { + appsEnabled, + settings: { + cluster: { domainSuffix }, + }, + } = useSession() + const status = useStatus() + const { + data: allBuilds, + isLoading: isLoadingAllBuilds, + isFetching: isFetchingAllBuilds, + refetch: refetchAllBuilds, + } = useGetAllBuildsQuery(teamId ? skipToken : undefined) + const { + data: teamBuilds, + isLoading: isLoadingTeamBuilds, + isFetching: isFetchingTeamBuilds, + refetch: refetchTeamBuilds, + } = useGetTeamBuildsQuery({ teamId }, { skip: !teamId }) + const isDirty = useAppSelector(({ global: { isDirty } }) => isDirty) + useEffect(() => { + if (isDirty !== false) return + if (!teamId && !isFetchingAllBuilds) refetchAllBuilds() + else if (teamId && !isFetchingTeamBuilds) refetchTeamBuilds() + }, [isDirty]) + // END HOOKS + const headCells: HeadCell[] = [ + { + id: 'name', + label: t('Name'), + renderer: (row: Row) => getBuildLink(row), + }, + { + id: 'mode', + label: t('Type'), + renderer: (row) => row.mode.type, + }, + { + id: 'trigger', + label: t('Webhook URL'), + renderer: (row: Row) => (row.trigger ? : ''), + }, + { + id: 'tekton', + label: t('Tekton'), + renderer: (row: Row) => getTektonTaskRunLink(row, domainSuffix), + }, + { + id: 'harbor', + label: t('Repository'), + renderer: (row: Row) => , + }, + { + id: 'tag', + label: t('Tag'), + renderer: (row) => row.tag, + }, + { + id: 'Status', + label: 'Status', + renderer: (row: Row) => getStatus(status?.builds?.[row.name]), + }, + ] + + if (!teamId) { + headCells.push({ + id: 'teamId', + label: t('Team'), + }) + } + + const customButtonText = () => Create container image + + const loading = isLoadingAllBuilds || isLoadingTeamBuilds + const builds = teamId ? teamBuilds : allBuilds + + const comp = !appsEnabled.harbor ? ( + + ) : ( + builds && ( + + ) + ) + return +} diff --git a/src/pages/code-repositories/create-edit/create-edit.styles.ts b/src/pages/code-repositories/create-edit/create-edit.styles.ts index 135e66d48..256b414a5 100644 --- a/src/pages/code-repositories/create-edit/create-edit.styles.ts +++ b/src/pages/code-repositories/create-edit/create-edit.styles.ts @@ -1,28 +1,6 @@ import { makeStyles } from 'tss-react/mui' -import type { Theme } from '@mui/material/styles' -export const useStyles = makeStyles()((theme: Theme) => ({ - root: { - '& .mlMain': { - flexBasis: '100%', - maxWidth: '100%', - [theme.breakpoints.up('lg')]: { - flexBasis: '78.8%', - maxWidth: '78.8%', - }, - }, - '& .mlSidebar': { - flexBasis: '100%', - maxWidth: '100%', - position: 'static', - [theme.breakpoints.up('lg')]: { - flexBasis: '21.2%', - maxWidth: '21.2%', - position: 'sticky', - }, - width: '100%', - }, - }, +export const useStyles = makeStyles()(() => ({ link: { fontSize: '0.725rem', fontWeight: 400, diff --git a/src/pages/code-repositories/create-edit/create-edit.validator.ts b/src/pages/code-repositories/create-edit/create-edit.validator.ts index 399d111d1..dd7c3c37e 100644 --- a/src/pages/code-repositories/create-edit/create-edit.validator.ts +++ b/src/pages/code-repositories/create-edit/create-edit.validator.ts @@ -1,13 +1,20 @@ import { boolean, object, string } from 'yup' // Custom validation for repositoryUrl -const urlValidation = string().test('is-valid-url', 'Invalid URL for the selected git service', function (value) { - const { gitService } = this.parent - if (gitService === 'gitea') return value.startsWith('https://gitea') - if (gitService === 'github') return /^(https:\/\/github\.com\/.+|git@github\.com:.+\.git)$/.test(value) - if (gitService === 'gitlab') return /^(https:\/\/gitlab\.com\/.+|git@gitlab\.com:.+\.git)$/.test(value) - return true -}) +const urlValidation = string() + .test('is-valid-url', 'Invalid URL for the selected git service', function (value) { + const { gitService } = this.parent + if (gitService === 'gitea') return value.startsWith('https://gitea') + if (gitService === 'github') return /^(https:\/\/github\.com\/.+|git@github\.com:.+\.git)$/.test(value) + if (gitService === 'gitlab') return /^(https:\/\/gitlab\.com\/.+|git@gitlab\.com:.+\.git)$/.test(value) + return true + }) + .test('is-unique', 'Repository URL must be unique.', function (value) { + const { codeRepoUrls, validateOnSubmit } = this.options.context || {} + // Only validate uniqueness if `validateOnSubmit` is true + if (!validateOnSubmit) return true + return !codeRepoUrls.some((repoUrl) => repoUrl === value) + }) // Main validation export const coderepoApiResponseSchema = object({ diff --git a/src/pages/code-repositories/create-edit/index.tsx b/src/pages/code-repositories/create-edit/index.tsx index 96747b387..4f9afe6b0 100644 --- a/src/pages/code-repositories/create-edit/index.tsx +++ b/src/pages/code-repositories/create-edit/index.tsx @@ -16,6 +16,7 @@ import { useGetCodeRepoQuery, useGetInternalRepoUrlsQuery, useGetSealedSecretsQuery, + useGetTeamCodeReposQuery, useGetTestRepoConnectQuery, } from 'redux/otomiApi' import { useTranslation } from 'react-i18next' @@ -42,7 +43,7 @@ interface Params { codeRepositoryName?: string } -export default function ({ +export default function CreateEditCodeRepositories({ match: { params: { teamId, codeRepositoryName }, }, @@ -88,6 +89,7 @@ export default function ({ { teamId, codeRepositoryName }, { skip: !codeRepositoryName }, ) + const { data: teamCodeRepositories } = useGetTeamCodeReposQuery({ teamId }, { skip: !teamId }) const { data: teamSealedSecrets, isLoading: isLoadingTeamSecrets, @@ -97,7 +99,7 @@ export default function ({ } = useGetSealedSecretsQuery({ teamId }, { skip: !teamId }) const teamSecrets = teamSealedSecrets?.filter( - (secret) => secret.type === 'kubernetes.io/basic-auth' || secret.type === 'kubernetes.io/ssh-auth', + (secret) => secret?.type === 'kubernetes.io/basic-auth' || secret?.type === 'kubernetes.io/ssh-auth', ) || [] const { data: internalRepoUrls, @@ -121,9 +123,11 @@ export default function ({ // form state const defaultValues = { gitService: 'gitea' as 'gitea' | 'github' | 'gitlab', ...prefilledData } + const codeRepoUrls = (teamCodeRepositories || []).map((codeRepo) => codeRepo.repositoryUrl) const methods = useForm({ resolver: yupResolver(coderepoApiResponseSchema) as Resolver, defaultValues: data || defaultValues, + context: { codeRepoUrls, validateOnSubmit: !codeRepositoryName }, }) const { control, @@ -191,16 +195,16 @@ export default function ({ } const mutating = isLoadingCreate || isLoadingUpdate || isLoadingDelete if (!mutating && (isSuccessCreate || isSuccessUpdate || isSuccessDelete)) - return + return const loading = isLoading || isLoadingTeamSecrets || isLoadingRepoUrls || (codeRepositoryName && !internalRepoUrls) const error = isError || isErrorTeamSecrets || isErrorRepoUrls - if (loading) return + if (loading) return return ( - - + + { @@ -220,6 +224,7 @@ export default function ({ }} error={!!errors.name} helperText={errors.name?.message?.toString()} + disabled={!!codeRepositoryName} /> @@ -234,6 +239,7 @@ export default function ({ onChange={(value) => { setGitProvider(value) }} + disabled={!!codeRepositoryName} /> {gitProvider === 'gitea' && internalRepoUrls ? ( @@ -250,6 +256,7 @@ export default function ({ width='large' value={watch('repositoryUrl') || ''} select + disabled={!!codeRepositoryName} > Select a code repository @@ -281,6 +288,7 @@ export default function ({ error={!!errors.repositoryUrl} helperText={errors.repositoryUrl?.message} width='large' + disabled={!!codeRepositoryName} /> )} - + {/* Hide edit button for Gitea */} + {!(codeRepositoryName && gitProvider === 'gitea') && ( + + {codeRepositoryName ? 'Edit Code Repository' : 'Add Code Repository'} + + )} diff --git a/src/pages/code-repositories/overview/index.tsx b/src/pages/code-repositories/overview/index.tsx index 117ea0040..89377d975 100644 --- a/src/pages/code-repositories/overview/index.tsx +++ b/src/pages/code-repositories/overview/index.tsx @@ -6,15 +6,14 @@ import { RouteComponentProps } from 'react-router-dom' import { getRole } from 'utils/data' import { useGetAllCodeReposQuery, useGetTeamCodeReposQuery } from 'redux/otomiApi' import { useAppSelector } from 'redux/hooks' -import { Typography } from '@mui/material' import { HeadCell } from '../../../components/EnhancedTable' import RLink from '../../../components/Link' import ListTable from '../../../components/ListTable' -const getCodeRepoLabel = (): CallableFunction => +const getCodeRepoName = (): CallableFunction => function (row): string | React.ReactElement { const { teamId, name }: { teamId: string; name: string } = row - const path = `/teams/${teamId}/coderepositories/${encodeURIComponent(name)}` + const path = `/teams/${teamId}/code-repositories/${encodeURIComponent(name)}` return ( {name} @@ -54,7 +53,7 @@ interface Params { teamId?: string } -export default function ({ +export default function CodeRepositoriesOverview({ match: { params: { teamId }, }, @@ -85,9 +84,9 @@ export default function ({ const headCells: HeadCell[] = [ { - id: 'label', - label: t('Label'), - renderer: getCodeRepoLabel(), + id: 'name', + label: t('Name'), + renderer: getCodeRepoName(), }, { id: 'url', @@ -107,20 +106,16 @@ export default function ({ }) } - const customButtonText = () => ( - - Add Code Repository - - ) + const customButtonText = () => Add Code Repository const comp = ( ) - return + return } diff --git a/src/redux/otomiApi.ts b/src/redux/otomiApi.ts index 37961053b..82547d7ff 100644 --- a/src/redux/otomiApi.ts +++ b/src/redux/otomiApi.ts @@ -494,6 +494,12 @@ const injectedRtkApi = api.injectEndpoints({ getSettingsInfo: build.query({ query: () => ({ url: `/v1/settingsInfo` }), }), + getRepoBranches: build.query({ + query: (queryArg) => ({ + url: `/v1/repoBranches`, + params: { codeRepoName: queryArg.codeRepoName, teamId: queryArg.teamId }, + }), + }), getTestRepoConnect: build.query({ query: (queryArg) => ({ url: `/v1/testRepoConnect`, @@ -3233,6 +3239,7 @@ export type GetAllBuildsApiResponse = /** status 200 Successfully obtained all b id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -3269,6 +3276,7 @@ export type GetTeamBuildsApiResponse = /** status 200 Successfully obtained team id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -3308,6 +3316,7 @@ export type CreateBuildApiResponse = /** status 200 Successfully stored build co id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -3347,6 +3356,7 @@ export type CreateBuildApiArg = { id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -3390,6 +3400,7 @@ export type GetBuildApiResponse = /** status 200 Successfully obtained build con id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -3431,6 +3442,7 @@ export type EditBuildApiResponse = /** status 200 Successfully edited a team bui id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -3472,6 +3484,7 @@ export type EditBuildApiArg = { id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -3510,6 +3523,7 @@ export type GetAllAplBuildsApiResponse = /** status 200 Successfully obtained al id?: string teamId?: string name?: string + imageName?: string tag?: string mode?: | { @@ -3567,6 +3581,7 @@ export type GetTeamAplBuildsApiResponse = /** status 200 Successfully obtained t id?: string teamId?: string name?: string + imageName?: string tag?: string mode?: | { @@ -3627,6 +3642,7 @@ export type CreateAplBuildApiResponse = /** status 200 Successfully stored build id?: string teamId?: string name?: string + imageName?: string tag?: string mode?: | { @@ -3687,6 +3703,7 @@ export type CreateAplBuildApiArg = { id?: string teamId?: string name?: string + imageName?: string tag?: string mode?: | { @@ -3737,6 +3754,7 @@ export type GetAplBuildApiResponse = /** status 200 Successfully obtained build id?: string teamId?: string name?: string + imageName?: string tag?: string mode?: | { @@ -3799,6 +3817,7 @@ export type EditAplBuildApiResponse = /** status 200 Successfully edited a team id?: string teamId?: string name?: string + imageName?: string tag?: string mode?: | { @@ -3861,6 +3880,7 @@ export type EditAplBuildApiArg = { id?: string teamId?: string name?: string + imageName?: string tag?: string mode?: | { @@ -4432,6 +4452,7 @@ export type GetAllProjectsApiResponse = /** status 200 Successfully obtained all id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -4568,6 +4589,7 @@ export type GetTeamProjectsApiResponse = /** status 200 Successfully obtained te id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -4707,6 +4729,7 @@ export type CreateProjectApiResponse = /** status 200 Successfully stored projec id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -4846,6 +4869,7 @@ export type CreateProjectApiArg = { id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -4989,6 +5013,7 @@ export type GetProjectApiResponse = /** status 200 Successfully obtained project id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -5130,6 +5155,7 @@ export type EditProjectApiResponse = /** status 200 Successfully edited a team p id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -5296,6 +5322,7 @@ export type GetAllAplProjectsApiResponse = /** status 200 Successfully obtained id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -5452,6 +5479,7 @@ export type GetTeamAplProjectsApiResponse = /** status 200 Successfully obtained id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -5611,6 +5639,7 @@ export type CreateAplProjectApiResponse = /** status 200 Successfully stored pro id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -5756,6 +5785,7 @@ export type CreateAplProjectApiArg = { id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -5919,6 +5949,7 @@ export type GetAplProjectApiResponse = /** status 200 Successfully obtained proj id?: string teamId?: string name: string + imageName?: string tag?: string mode?: | { @@ -7224,6 +7255,13 @@ export type GetSettingsInfoApiResponse = /** status 200 The request is successfu ingressClassNames?: string[] } export type GetSettingsInfoApiArg = void +export type GetRepoBranchesApiResponse = /** status 200 The request is successful. */ string[] +export type GetRepoBranchesApiArg = { + /** Name of the code repository */ + codeRepoName?: string + /** Id of the team */ + teamId?: string +} export type GetTestRepoConnectApiResponse = /** status 200 The request is successful. */ { url?: string status?: 'unknown' | 'success' | 'failed' @@ -7981,6 +8019,7 @@ export const { useGetSessionQuery, useApiDocsQuery, useGetSettingsInfoQuery, + useGetRepoBranchesQuery, useGetTestRepoConnectQuery, useGetInternalRepoUrlsQuery, useCreateObjWizardMutation, diff --git a/src/theme/overrides/Typography.ts b/src/theme/overrides/Typography.ts index ddaad2c82..49b501e85 100644 --- a/src/theme/overrides/Typography.ts +++ b/src/theme/overrides/Typography.ts @@ -47,6 +47,13 @@ export default function Typography(theme: Theme) { fontSize: '1rem', lineHeight: '1.4rem', }, + h6: { + color: theme.palette.cl.text.title, + fontFamily: font.bold, + fontWeight: 700, + fontSize: '1rem', + lineHeight: '1.125rem', + }, subtitle1: { color: theme.palette.cm.primaryText, fontSize: '1.075rem', diff --git a/src/theme/palette.ts b/src/theme/palette.ts index e19d8b995..140ca4d6e 100644 --- a/src/theme/palette.ts +++ b/src/theme/palette.ts @@ -286,9 +286,4 @@ const palette = { }, } as const -const font = { - bold: '"LatoWebBold", sans-serif', - normal: '"LatoWeb", sans-serif', -} as const - export default palette