/* * 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, 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 { generateDefaultStepInsertionPath } from 'components/SourceCodeEditor/EditorUtils' 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 } enum PluginCategory { Harness = 'run', Drone = 'plugin' } enum PluginPanelView { Category, Listing, Configuration } const PluginsInputPath = 'inputs' const PluginSpecPath = 'spec' const PluginSpecInputPath = `${PluginSpecPath}.${PluginsInputPath}` const LIST_FETCHING_LIMIT = 100 const RunStepSpec: TypesPlugin = { identifier: '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 { isUpdate, formData, pathToField } = pluginDataFromYAML setFormInitialValues( isUpdate ? getInitialFormValuesFromYAML({ pathToField, formData }) : getInitialFormValuesWithFieldDefaults( getPluginInputsFromSpec(get(plugin, PluginSpecPath, '') as string) as PluginInputs ) ) }, [plugin, pluginDataFromYAML]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { setShowSyncToolbar(!isEmpty(pluginFieldUpdateData.pathToField)) // 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.identifier?.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(PluginCategory.Harness) } else { setPluginCategory(PluginCategory.Drone) fetchAllPlugins().then(response => { const matchingPlugin = response?.find( (_plugin: TypesPlugin) => _plugin?.identifier === 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 { identifier, description } = pluginItem return ( { setPanelView(PluginPanelView.Configuration) setPlugin(pluginItem) }} key={identifier} width="100%"> {identifier} {description} ) })} ) }, [loading, plugins, query]) // eslint-disable-line react-hooks/exhaustive-deps const generateFriendlyName = (pluginName: string): string => { return capitalize(pluginName.split('_').join(' ')) } const generateLabelForPluginField = useCallback( ({ label, properties }: { label: string; properties: PluginInput }): JSX.Element | string => { const { description } = properties return ( {label && {generateFriendlyName(label)}} {description && ( {description} }> )} ) }, [] // eslint-disable-line react-hooks/exhaustive-deps ) const renderPluginFormField = useCallback( ({ label, identifier, name, properties }: { label: string identifier: string 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 ) 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, PluginSpecInputPath, {}) } 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( ({ pathToField, formData }: { pathToField: string[]; formData: PluginFormDataInterface }): PluginInputs => { let pluginInputsWithYAMLValues: PluginInputs = {} const fieldFormikPathPrefix = pathToField.join('.') if (!isEmpty(formData)) { const _category_ = get(formData, 'type') as PluginCategory pluginInputsWithYAMLValues = Object.entries( get(formData, _category_ === PluginCategory.Harness ? PluginSpecPath : PluginSpecInputPath, {}) ).reduce((acc, [field, value]) => { const formikFieldName = getFormikFieldName({ fieldName: field, fieldFormikPathPrefix, fieldFormikPathPrefixWithSpec: `${fieldFormikPathPrefix}.spec`, category: _category_ }) set(acc, formikFieldName, value) return acc }, {} as PluginInputs) } if (has(formData, 'name')) { set( pluginInputsWithYAMLValues, fieldFormikPathPrefix ? `${fieldFormikPathPrefix}.name` : 'name', get(formData, 'name') ) } return pluginInputsWithYAMLValues }, [] ) /** * Toolbar to sync updates from YAML into UI */ const renderFieldSyncToolbar = useCallback((): JSX.Element => { return ( {getString('pipelineConfig.yamlUpdated')}