/* * Copyright 2023 Harness, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import React, { useCallback, useEffect, useRef, useState } from 'react' import { Formik, FormikContextType } from 'formik' import { parse } from 'yaml' import cx from 'classnames' import { capitalize, get, has, isEmpty, isUndefined, omit, pick, set } from 'lodash-es' import type { IRange } from 'monaco-editor' import { Classes, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core' import { Color, FontVariation } from '@harnessio/design-system' import { Icon, IconProps } from '@harnessio/icons' import { Button, ButtonSize, ButtonVariation, Card, Container, ExpandingSearchInput, FormInput, FormikForm, Layout, Popover, Text } from '@harnessio/uicore' import type { TypesPlugin } from 'services/code' import { useStrings } from 'framework/strings' import { MultiList } from 'components/MultiList/MultiList' import MultiMap from 'components/MultiMap/MultiMap' import { PipelineEntity, Action, CodeLensClickMetaData } from 'components/PipelineConfigPanel/types' import { RunStep } from './Steps/HarnessSteps/RunStep/RunStep' import css from './PluginsPanel.module.scss' export interface EntityAddUpdateInterface extends Partial { pathToField: string[] range?: IRange isUpdate: boolean formData: PluginFormDataInterface } export interface PluginsPanelInterface { pluginDataFromYAML: EntityAddUpdateInterface onPluginAddUpdate: (data: EntityAddUpdateInterface) => void pluginFieldUpdateData: Partial } export interface PluginFormDataInterface { [key: string]: string | boolean | object } enum ValueType { STRING = 'string', BOOLEAN = 'boolean', NUMBER = 'number', ARRAY = 'array', OBJECT = 'object' } interface PluginInput { type: ValueType description?: string default?: string options?: { isExtended?: boolean } } interface PluginInputs { [key: string]: PluginInput } interface PluginCategoryInterface { category: PluginCategory name: string description: string icon: IconProps } interface PluginInsertionTemplateInterface { name?: string type: PluginCategory.Drone spec: { name: string inputs: { [key: string]: string } } } enum PluginCategory { Harness = 'run', Drone = 'plugin' } enum PluginPanelView { Category, Listing, Configuration } const PluginInsertionTemplate: PluginInsertionTemplateInterface = { name: '', type: PluginCategory.Drone, spec: { name: '', inputs: { '': '', '': '' } } } const PluginNameFieldPath = 'spec.name' const PluginInputsFieldPath = 'spec.inputs' const LIST_FETCHING_LIMIT = 100 const RunStepSpec: TypesPlugin = { uid: 'run' } export const PluginsPanel = (props: PluginsPanelInterface): JSX.Element => { const { pluginDataFromYAML, onPluginAddUpdate, pluginFieldUpdateData } = props const { getString } = useStrings() const [pluginCategory, setPluginCategory] = useState() const [panelView, setPanelView] = useState(PluginPanelView.Category) const [plugin, setPlugin] = useState() const [plugins, setPlugins] = useState([]) const [query, setQuery] = useState('') const [loading, setLoading] = useState(false) const formikRef = useRef>() const [showSyncToolbar, setShowSyncToolbar] = useState(false) const [formInitialValues, setFormInitialValues] = useState() const PluginCategories: PluginCategoryInterface[] = [ { category: PluginCategory.Harness, name: capitalize(getString('run')), description: getString('pluginsPanel.run.helptext'), icon: { name: 'run-step', size: 15 } }, { category: PluginCategory.Drone, name: capitalize(getString('plugins.title')), description: getString('pluginsPanel.plugins.helptext'), icon: { name: 'plugin-ci-step', size: 18 } } ] useEffect(() => { const { entity, action } = pluginDataFromYAML if (entity === PipelineEntity.STEP) { switch (action) { case Action.EDIT: handleIncomingPluginData(pluginDataFromYAML) break case Action.ADD: setPanelView(PluginPanelView.Category) break } } }, [pluginDataFromYAML]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const pluginInputs = getPluginInputsFromSpec(get(plugin, 'spec', '') as string) as PluginInputs const { isUpdate, formData } = pluginDataFromYAML setFormInitialValues( isUpdate && pluginCategory ? getInitialFormValuesFromYAML(pluginCategory, formData) : getInitialFormValuesWithFieldDefaults(pluginInputs) ) }, [plugin, pluginDataFromYAML, pluginCategory]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { setShowSyncToolbar(!isEmpty(pluginFieldUpdateData.formData)) // check with actual formik value as well }, [pluginFieldUpdateData]) useEffect(() => { if (pluginCategory === PluginCategory.Drone) { fetchAllPlugins().then(response => setPlugins(response)) } }, [pluginCategory]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (panelView !== PluginPanelView.Listing) return if (query) { setPlugins(existingPlugins => existingPlugins.filter((item: TypesPlugin) => item.uid?.includes(query))) } else { fetchAllPlugins().then(response => setPlugins(response)) } }, [panelView, query]) // eslint-disable-line react-hooks/exhaustive-deps const fetchPlugins = async (page: number): Promise => { const response = await fetch(`/api/v1/plugins?page=${page}&limit=${LIST_FETCHING_LIMIT}`) if (!response.ok) throw new Error('Failed to fetch plugins') return response.json() } const fetchAllPlugins = useCallback(async (): Promise => { try { setLoading(true) const pluginsPage1 = await fetchPlugins(1) const pluginsPage2 = await fetchPlugins(2) return [...pluginsPage1, ...pluginsPage2] } catch (ex) { /* ignore exception */ } finally { setLoading(false) } return [] }, []) const handleIncomingPluginData = useCallback( (data: EntityAddUpdateInterface) => { const { formData } = data const _category = get(formData, 'type') as PluginCategory if (_category === PluginCategory.Harness) { handlePluginCategoryClick(_category) } else { setPluginCategory(PluginCategory.Drone) fetchAllPlugins().then(response => { const matchingPlugin = response?.find((_plugin: TypesPlugin) => _plugin?.uid === get(formData, 'spec.name')) if (matchingPlugin) { setPlugin(matchingPlugin) setPanelView(PluginPanelView.Configuration) } }) } }, [fetchAllPlugins] // eslint-disable-line react-hooks/exhaustive-deps ) const handlePluginCategoryClick = useCallback((selectedCategory: PluginCategory) => { setPluginCategory(selectedCategory) if (selectedCategory === PluginCategory.Drone) { setPanelView(PluginPanelView.Listing) } else if (selectedCategory === PluginCategory.Harness) { setPlugin(RunStepSpec) setPanelView(PluginPanelView.Configuration) } }, []) const renderPluginCategories = useCallback((): JSX.Element => { return ( {getString('stepCategory.select')} {PluginCategories.map((item: PluginCategoryInterface) => { const { name, category, description, icon } = item return ( handlePluginCategoryClick(category)}> handlePluginCategoryClick(category)} flex={{ justifyContent: 'flex-start' }} className={css.cursor}> {name} {description} handlePluginCategoryClick(category)} className={css.cursor} /> ) })} ) }, [PluginCategories]) // eslint-disable-line react-hooks/exhaustive-deps const renderPlugins = useCallback((): JSX.Element => { return loading ? ( ) : ( { setPanelView(PluginPanelView.Category) }} className={css.arrow} /> {getString('plugins.select')} {plugins?.map((pluginItem: TypesPlugin) => { const { uid, description } = pluginItem return ( { setPanelView(PluginPanelView.Configuration) setPlugin(pluginItem) }} key={uid} width="100%"> {uid} {description} ) })} ) }, [loading, plugins, query]) // eslint-disable-line react-hooks/exhaustive-deps const generateFriendlyName = (pluginName: string): string => { return capitalize(pluginName.split('_').join(' ')) } const generateLabelForPluginField = useCallback( ({ name, properties }: { name: string; properties: PluginInput }): JSX.Element | string => { const { description } = properties return ( {name && {generateFriendlyName(name)}} {description && ( {description} }> )} ) }, [] // eslint-disable-line react-hooks/exhaustive-deps ) const renderPluginFormField = useCallback( ({ name, properties }: { name: string; properties: PluginInput }): JSX.Element => { const { type, options } = properties switch (type) { case ValueType.STRING: { const { isExtended } = options || {} const WrapperComponent = isExtended ? FormInput.TextArea : FormInput.Text return ( ) } case ValueType.BOOLEAN: return ( ) case ValueType.ARRAY: return ( ) case ValueType.OBJECT: return ( ) default: return <> } }, [] // eslint-disable-line react-hooks/exhaustive-deps ) /** * * Ensures no junk/unrecognized form values are set in the YAML * Only fields part of Plugin spec are set into the YAML */ const sanitizeFormData = useCallback( (existingFormData: PluginFormDataInterface, pluginInputs: PluginInputs): PluginFormDataInterface => { return pick(existingFormData, Object.keys(pluginInputs)) }, [] ) const constructPayloadForYAMLInsertion = useCallback( ({ pluginFormData, pluginMetadata }): PluginFormDataInterface => { const { container = {}, name } = pluginFormData const formDataWithoutName = omit(pluginFormData, 'name') let payload = { ...PluginInsertionTemplate } switch (pluginCategory) { case PluginCategory.Drone: /* Step name is optional, set only if specified by user */ if (name) { set(payload, 'name', name) } else { payload = omit(payload, 'name') } set(payload, PluginNameFieldPath, pluginMetadata?.uid) set(payload, PluginInputsFieldPath, formDataWithoutName) return payload case PluginCategory.Harness: return { ...(name && { name }), type: PluginCategory.Harness, ...(Object.keys(container).length === 1 && has(container, 'image') ? { spec: { ...formDataWithoutName, container: get(container, 'image') } } : { spec: formDataWithoutName }) } default: return {} } }, [pluginCategory] ) const insertNameFieldToPluginInputs = (existingInputs: { [key: string]: PluginInput }): { [key: string]: PluginInput } => { const inputsClone = Object.assign( { name: { type: 'string', description: 'Name of the step' } }, existingInputs ) return inputsClone } const getPluginInputsFromSpec = useCallback((pluginSpec: string): PluginInputs => { if (!pluginSpec) { return {} } try { const pluginSpecAsObj = parse(pluginSpec) return get(pluginSpecAsObj, 'spec.inputs', {}) } catch (ex) { /* ignore error */ } return {} }, []) const getInitialFormValuesWithFieldDefaults = useCallback((pluginInputs: PluginInputs): PluginInputs => { return Object.entries(pluginInputs).reduce((acc, [field, inputObj]) => { if (inputObj?.default) { set(acc, field, inputObj.default) } return acc }, {} as PluginInputs) }, []) const getInitialFormValuesFromYAML = useCallback( (_category: PluginCategory, formValues: PluginFormDataInterface): PluginInputs => { return Object.assign( { name: get(formValues, 'name') }, formValues ? Object.entries(get(formValues, _category === PluginCategory.Harness ? 'spec' : 'spec.inputs', {})).reduce( (acc, [field, value]) => { set(acc, field, value) return acc }, {} as PluginInputs ) : {} ) }, [] ) /** * Toolbar to sync updates from YAML into UI */ const renderFieldSyncToolbar = useCallback((): JSX.Element => { return ( {getString('pipelineConfig.yamlUpdated')}