diff --git a/web/config/webpack.common.js b/web/config/webpack.common.js index e9e6cd9e8..b4561a0e6 100644 --- a/web/config/webpack.common.js +++ b/web/config/webpack.common.js @@ -221,6 +221,18 @@ module.exports = { 'vb', 'xml', 'yaml' + ], + globalAPI: true, + filename: '[name].worker.[contenthash:6].js', + customLanguages: [ + { + label: 'yaml', + entry: 'monaco-yaml', + worker: { + id: 'monaco-yaml/yamlWorker', + entry: 'monaco-yaml/yaml.worker' + } + } ] }) ] diff --git a/web/package.json b/web/package.json index 78e9e44be..adab8327c 100644 --- a/web/package.json +++ b/web/package.json @@ -65,6 +65,7 @@ "moment": "^2.25.3", "monaco-editor": "^0.40.0", "monaco-editor-webpack-plugin": "^7.1.0", + "monaco-yaml": "^4.0.4", "qs": "^6.9.4", "react": "^17.0.2", "react-complex-tree": "^1.1.11", diff --git a/web/src/RouteDefinitions.ts b/web/src/RouteDefinitions.ts index 65d79cc0b..1d40d7384 100644 --- a/web/src/RouteDefinitions.ts +++ b/web/src/RouteDefinitions.ts @@ -46,7 +46,7 @@ export interface CODERoutes { toCODESpaceAccessControl: (args: Required>) => string toCODESpaceSettings: (args: Required>) => string toCODEPipelines: (args: Required>) => string - toCODEPipelinesNew: (args: Required>) => string + toCODEPipelineEdit: (args: Required>) => string toCODESecrets: (args: Required>) => string toCODEGlobalSettings: () => string @@ -97,7 +97,7 @@ export const routes: CODERoutes = { toCODESpaceAccessControl: ({ space }) => `/access-control/${space}`, toCODESpaceSettings: ({ space }) => `/settings/${space}`, toCODEPipelines: ({ repoPath }) => `/${repoPath}/pipelines`, - toCODEPipelinesNew: ({ space }) => `/pipelines/${space}/new`, + toCODEPipelineEdit: ({ repoPath, pipeline }) => `/${repoPath}/pipelines/${pipeline}/edit`, toCODESecrets: ({ space }) => `/secrets/${space}`, toCODEGlobalSettings: () => '/settings', diff --git a/web/src/RouteDestinations.tsx b/web/src/RouteDestinations.tsx index d7cef09bb..2b6642f85 100644 --- a/web/src/RouteDestinations.tsx +++ b/web/src/RouteDestinations.tsx @@ -31,7 +31,7 @@ import { useFeatureFlag } from 'hooks/useFeatureFlag' import ExecutionList from 'pages/ExecutionList/ExecutionList' import Execution from 'pages/Execution/Execution' import Secret from 'pages/Secret/Secret' -import NewPipeline from 'pages/NewPipeline/NewPipeline' +import AddUpdatePipeline from 'pages/AddUpdatePipeline/AddUpdatePipeline' export const RouteDestinations: React.FC = React.memo(function RouteDestinations() { const { getString } = useStrings() @@ -186,9 +186,9 @@ export const RouteDestinations: React.FC = React.memo(function RouteDestinations )} {OPEN_SOURCE_PIPELINES && ( - + - + )} diff --git a/web/src/components/NewPipelineModal/NewPipelineModal.module.scss b/web/src/components/NewPipelineModal/NewPipelineModal.module.scss new file mode 100644 index 000000000..23d8222cf --- /dev/null +++ b/web/src/components/NewPipelineModal/NewPipelineModal.module.scss @@ -0,0 +1,17 @@ +.branchSelect { + :global { + .bp3-popover-wrapper { + width: 100% !important; + .bp3-popover-target { + width: 100% !important; + } + } + .bp3-button { + justify-content: start; + width: 100%; + } + .bp3-icon-chevron-down { + margin-left: auto; + } + } +} diff --git a/web/src/components/NewPipelineModal/NewPipelineModal.module.scss.d.ts b/web/src/components/NewPipelineModal/NewPipelineModal.module.scss.d.ts new file mode 100644 index 000000000..f9139a9e7 --- /dev/null +++ b/web/src/components/NewPipelineModal/NewPipelineModal.module.scss.d.ts @@ -0,0 +1,3 @@ +/* eslint-disable */ +// This is an auto-generated file +export declare const branchSelect: string diff --git a/web/src/components/NewPipelineModal/NewPipelineModal.tsx b/web/src/components/NewPipelineModal/NewPipelineModal.tsx new file mode 100644 index 000000000..5bfb5807b --- /dev/null +++ b/web/src/components/NewPipelineModal/NewPipelineModal.tsx @@ -0,0 +1,162 @@ +import React, { useMemo, useState } from 'react' +import { useHistory } from 'react-router-dom' +import { useMutate } from 'restful-react' +import * as yup from 'yup' +import { capitalize } from 'lodash' +import { + Button, + ButtonVariation, + Container, + Dialog, + FormInput, + Formik, + FormikForm, + Layout, + Text, + useToaster +} from '@harnessio/uicore' +import { FontVariation } from '@harnessio/design-system' +import { useModalHook } from 'hooks/useModalHook' +import type { OpenapiCreatePipelineRequest, TypesPipeline, TypesRepository } from 'services/code' +import { useStrings } from 'framework/strings' +import { BranchTagSelect } from 'components/BranchTagSelect/BranchTagSelect' +import { useAppContext } from 'AppContext' +import { getErrorMessage } from 'utils/Utils' +import { DEFAULT_YAML_PATH_PREFIX, DEFAULT_YAML_PATH_SUFFIX } from '../../pages/AddUpdatePipeline/Constants' + +import css from './NewPipelineModal.module.scss' + +interface FormData { + name: string + branch: string + yamlPath: string +} + +const useNewPipelineModal = () => { + const { routes } = useAppContext() + const { getString } = useStrings() + const history = useHistory() + const { showError } = useToaster() + const [repo, setRepo] = useState() + const repoPath = useMemo(() => repo?.path || '', [repo]) + + const { mutate: savePipeline } = useMutate({ + verb: 'POST', + path: `/api/v1/repos/${repoPath}/+/pipelines` + }) + + const handleCreatePipeline = (formData: FormData): void => { + const { name, branch, yamlPath } = formData + try { + const payload: OpenapiCreatePipelineRequest = { + config_path: yamlPath, + default_branch: branch, + uid: name + } + savePipeline(payload, { pathParams: { path: `/api/v1/repos/${repoPath}/+/pipelines` } }) + .then(() => { + hideModal() + history.push(routes.toCODEPipelineEdit({ repoPath, pipeline: name })) + }) + .catch(error => { + showError(getErrorMessage(error), 0, 'pipelines.failedToCreatePipeline') + }) + } catch (exception) { + showError(getErrorMessage(exception), 0, 'pipelines.failedToCreatePipeline') + } + } + + const [openModal, hideModal] = useModalHook(() => { + const onClose = () => { + hideModal() + } + return ( + + + initialValues={{ name: '', branch: repo?.default_branch || '', yamlPath: '' }} + formName="createNewPipeline" + enableReinitialize={true} + validationSchema={yup.object().shape({ + name: yup + .string() + .trim() + .required(`${getString('name')} ${getString('isRequired')}`), + branch: yup + .string() + .trim() + .required(`${getString('branch')} ${getString('isRequired')}`), + yamlPath: yup + .string() + .trim() + .required(`${getString('pipelines.yamlPath')} ${getString('isRequired')}`) + })} + validateOnChange + validateOnBlur + onSubmit={handleCreatePipeline}> + {formik => { + return ( + + + + { + const input = (event.target as HTMLInputElement)?.value + formik?.setFieldValue('name', input) + if (input) { + // Keeping minimal validation for now, this could be much more exhaustive + const path = input.trim().replace(/\s/g, '') + formik?.setFieldValue( + 'yamlPath', + DEFAULT_YAML_PATH_PREFIX.concat(path).concat(DEFAULT_YAML_PATH_SUFFIX) + ) + } + }} + /> + + {capitalize(getString('branch'))} + + { + formik?.setFieldValue('branch', ref) + }} + repoMetadata={repo || {}} + disableBranchCreation + disableViewAllBranches + forBranchesOnly + /> + + + + + + + ) + }, [repo]) + + return { + openModal: ({ repoMetadata }: { repoMetadata?: TypesRepository }) => { + setRepo(repoMetadata) + openModal() + }, + hideModal + } +} + +export default useNewPipelineModal diff --git a/web/src/components/PluginsPanel/PluginsPanel.module.scss b/web/src/components/PluginsPanel/PluginsPanel.module.scss new file mode 100644 index 000000000..d5d2232cd --- /dev/null +++ b/web/src/components/PluginsPanel/PluginsPanel.module.scss @@ -0,0 +1,53 @@ +.main { + height: 100%; + :global { + .bp3-tabs { + width: 100%; + height: 100%; + } + } +} + +.mainTabPanel { + &:global(.bp3-tab-panel[role='tabpanel']) { + height: calc(100% - 30px); + margin-top: 0 !important; + margin-bottom: 0 !important; + } +} + +.pluginDetailsPanel { + height: 100%; + border-top: 1px solid var(--grey-100); +} + +.pluginIcon { + background: var(--teal-200) !important; + border-radius: 5px; +} + +.plugin { + border: 1px solid var(--grey-100); + &:hover { + cursor: pointer; + } +} + +.form { + height: 100%; + width: 100%; + :global { + .FormikForm--main { + height: 100%; + & > div { + height: 100%; + } + } + } +} + +.arrow { + &:hover { + cursor: pointer; + } +} diff --git a/web/src/components/PluginsPanel/PluginsPanel.module.scss.d.ts b/web/src/components/PluginsPanel/PluginsPanel.module.scss.d.ts new file mode 100644 index 000000000..cc220df9e --- /dev/null +++ b/web/src/components/PluginsPanel/PluginsPanel.module.scss.d.ts @@ -0,0 +1,9 @@ +/* eslint-disable */ +// This is an auto-generated file +export declare const arrow: string +export declare const form: string +export declare const main: string +export declare const mainTabPanel: string +export declare const plugin: string +export declare const pluginDetailsPanel: string +export declare const pluginIcon: string diff --git a/web/src/components/PluginsPanel/PluginsPanel.tsx b/web/src/components/PluginsPanel/PluginsPanel.tsx new file mode 100644 index 000000000..29e16c50c --- /dev/null +++ b/web/src/components/PluginsPanel/PluginsPanel.tsx @@ -0,0 +1,300 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { Formik } from 'formik' +import { capitalize, get } from 'lodash-es' +import { useGet } from 'restful-react' +import { useStrings } from 'framework/strings' +import { Button, ButtonVariation, Container, FormInput, FormikForm, Layout, Tab, Tabs, Text } from '@harnessio/uicore' +import { Color, FontVariation } from '@harnessio/design-system' +import { Icon, type IconName } from '@harnessio/icons' +import { LIST_FETCHING_LIMIT } from 'utils/Utils' +import type { TypesPlugin } from 'services/code' +import { YamlVersion } from 'pages/AddUpdatePipeline/Constants' + +import css from './PluginsPanel.module.scss' + +enum PluginCategory { + Harness, + Drone +} + +enum PluginPanelView { + Category, + Listing, + Configuration +} + +interface PluginInterface { + category: PluginCategory + name: string + description: string + icon: IconName +} + +const PluginCategories: PluginInterface[] = [ + { + category: PluginCategory.Harness, + name: 'Run', + description: 'Run a script on macOS, Linux, or Windows', + icon: 'run-step' + }, + { category: PluginCategory.Drone, name: 'Drone', description: 'Run Drone plugins', icon: 'ci-infra' } +] + +const dronePluginSpecMockData = { + inputs: { + channel: { + type: 'string' + }, + token: { + type: 'string' + } + }, + steps: [ + { + type: 'script', + spec: { + image: 'plugins/slack' + }, + envs: { + PLUGIN_CHANNEL: '<+inputs.channel>' + } + } + ] +} + +const runStepSpec = { + inputs: { + script: { + type: 'string' + } + } +} + +export interface PluginsPanelInterface { + version?: YamlVersion + onPluginAddUpdate: (isUpdate: boolean, pluginFormData: Record) => void +} + +export const PluginsPanel = ({ version = YamlVersion.V0, onPluginAddUpdate }: PluginsPanelInterface): JSX.Element => { + const { getString } = useStrings() + const [category, setCategory] = useState() + const [panelView, setPanelView] = useState(PluginPanelView.Category) + const [plugin, setPlugin] = useState() + + const { + data: plugins, + loading, + refetch: fetchPlugins + } = useGet({ + path: `/api/v1/plugins`, + queryParams: { + limit: LIST_FETCHING_LIMIT, + page: 1 + }, + lazy: true + }) + + useEffect(() => { + if (category === PluginCategory.Drone) { + fetchPlugins() + } + }, [category]) + + const renderPluginCategories = (): JSX.Element => { + return ( + <> + {PluginCategories.map((item: PluginInterface) => { + const { name, category, description, icon } = item + return ( + { + setCategory(category) + if (category === PluginCategory.Drone) { + setPanelView(PluginPanelView.Listing) + } else if (category === PluginCategory.Harness) { + setPlugin({ uid: getString('run') }) + setPanelView(PluginPanelView.Configuration) + } + }} + key={category} + padding={{ left: 'medium', right: 'medium', top: 'medium', bottom: 'medium' }} + flex={{ justifyContent: 'flex-start' }} + className={css.plugin}> + + + + + + {name} + + {description} + + + ) + })} + + ) + } + + const renderPlugins = useCallback((): JSX.Element => { + return loading ? ( + + + + ) : ( + + + { + setPanelView(PluginPanelView.Category) + }} + className={css.arrow} + /> + + {getString('plugins.addAPlugin', { category: PluginCategory[category as PluginCategory] })} + + + + {plugins?.map((plugin: TypesPlugin) => { + const { uid, description } = plugin + return ( + { + setPanelView(PluginPanelView.Configuration) + setPlugin(plugin) + }}> + + + + {uid} + + {description} + + + ) + })} + + + ) + }, [loading, plugins]) + + const renderPluginFormField = ({ name, type }: { name: string; type: 'string' }): JSX.Element => { + return type === 'string' ? ( + {capitalize(name)}} + style={{ width: '100%' }} + key={name} + /> + ) : ( + <> + ) + } + + const constructPayloadForYAMLInsertion = (isUpdate: boolean, pluginFormData: Record) => { + let constructedPayload = { ...pluginFormData } + switch (category) { + case PluginCategory.Drone: + case PluginCategory.Harness: + constructedPayload = + version === YamlVersion.V1 + ? { type: 'script', spec: constructedPayload } + : { name: 'run step', commands: [get(constructedPayload, 'script', '')] } + } + onPluginAddUpdate?.(isUpdate, constructedPayload) + } + + const renderPluginConfigForm = useCallback((): JSX.Element => { + // TODO obtain plugin input spec by parsing YAML + const inputs = get(category === PluginCategory.Drone ? dronePluginSpecMockData : runStepSpec, 'inputs', {}) + return ( + + + { + setPlugin(undefined) + if (category === PluginCategory.Drone) { + setPanelView(PluginPanelView.Listing) + } else if (category === PluginCategory.Harness) { + setPanelView(PluginPanelView.Category) + } + }} + className={css.arrow} + /> + {plugin?.uid ? ( + + {getString('addLabel')} {plugin.uid} {getString('plugins.stepLabel')} + + ) : ( + <> + )} + + + { + constructPayloadForYAMLInsertion(false, formData) + }}> + + + + {Object.keys(inputs).map((field: string) => { + const fieldType = get(inputs, `${field}.type`, '') as 'string' + return renderPluginFormField({ name: field, type: fieldType }) + })} + + + openModal({ repoMetadata }) + }} + disabled={loading} + /> ) const columns: Column[] = useMemo( () => [ { Header: getString('pipelines.name'), - width: 'calc(50% - 90px)', + width: 'calc(100% - 210px)', Cell: ({ row }: CellProps) => { const record = row.original return ( @@ -188,6 +193,46 @@ const PipelineList = () => { ) }, disableSortBy: true + }, + { + Header: ' ', + width: '30px', + Cell: ({ row }: CellProps) => { + const [menuOpen, setMenuOpen] = useState(false) + const record = row.original + const { uid } = record + return ( + { + setMenuOpen(nextOpenState) + }} + className={Classes.DARK} + position={Position.BOTTOM_RIGHT}> +