import React, { useCallback, useEffect, useState } from 'react' import { Formik } from 'formik' import { parse } from 'yaml' import { capitalize, get, has, omit, set } from 'lodash-es' import { Classes, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core' import { Color, FontVariation } from '@harnessio/design-system' import { Icon, IconProps } from '@harnessio/icons' import { Accordion, Button, ButtonVariation, Card, Container, ExpandingSearchInput, FormInput, FormikForm, Layout, Popover, Text } from '@harnessio/uicore' import type { TypesPlugin } from 'services/code' import { useStrings } from 'framework/strings' import css from './PluginsPanel.module.scss' export interface PluginsPanelInterface { onPluginAddUpdate: (isUpdate: boolean, pluginFormData: PluginForm) => void } export interface PluginForm { [key: string]: string | boolean | object } interface PluginInput { type: 'string' 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: 'plugin' spec: { name: string inputs: { [key: string]: string } } } enum PluginCategory { Harness, Drone } enum PluginPanelView { Category, Listing, Configuration } const PluginInsertionTemplate: PluginInsertionTemplateInterface = { name: '', type: 'plugin', spec: { name: '', inputs: { '': '', '': '' } } } const PluginNameFieldPath = 'spec.name' const PluginInputsFieldPath = 'spec.inputs' const LIST_FETCHING_LIMIT = 100 const RunStep: TypesPlugin = { uid: 'run' } export const PluginsPanel = ({ onPluginAddUpdate }: PluginsPanelInterface): JSX.Element => { const { getString } = useStrings() const [category, setCategory] = 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 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 } } ] 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) setPlugins([...pluginsPage1, ...pluginsPage2]) } catch (ex) { /* ignore exception */ } finally { setLoading(false) } }, []) useEffect(() => { if (category === PluginCategory.Drone) { fetchAllPlugins() } }, [category]) useEffect(() => { if (panelView !== PluginPanelView.Listing) return if (query) { setPlugins(existingPlugins => existingPlugins.filter((item: TypesPlugin) => item.uid?.includes(query))) } else { fetchAllPlugins() } }, [query]) const handlePluginCategoryClick = useCallback((selectedCategory: PluginCategory) => { setCategory(selectedCategory) if (selectedCategory === PluginCategory.Drone) { setPanelView(PluginPanelView.Listing) } else if (selectedCategory === PluginCategory.Harness) { setPlugin(RunStep) setPanelView(PluginPanelView.Configuration) } }, []) const renderPluginCategories = (): JSX.Element => { return ( <> {PluginCategories.map((item: PluginCategoryInterface) => { const { name, category: pluginCategory, description, icon } = item return ( handlePluginCategoryClick(pluginCategory)} flex={{ justifyContent: 'flex-start' }} className={css.plugin}> {name} {description} handlePluginCategoryClick(pluginCategory)} className={css.plugin} /> ) })} ) } 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}> {uid} {description} ) })} ) }, [loading, plugins, query]) const generateFriendlyName = useCallback((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} }> )} ) }, [] ) const renderPluginFormField = ({ name, properties }: { name: string; properties: PluginInput }): JSX.Element => { const { type, options } = properties const { isExtended } = options || {} const WrapperComponent = isExtended ? FormInput.TextArea : FormInput.Text return type === 'string' ? ( ) : ( <> ) } const constructPayloadForYAMLInsertion = (pluginFormData: PluginForm, pluginMetadata?: TypesPlugin): PluginForm => { const { name, container = {} } = pluginFormData let payload = { ...PluginInsertionTemplate } switch (category) { 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, omit(pluginFormData, 'name')) return payload case PluginCategory.Harness: return { ...(name && { name }), type: 'run', ...(Object.keys(container).length === 1 && has(container, 'image') ? { spec: { ...pluginFormData, container: get(container, 'image') } } : { spec: pluginFormData }) } default: return {} } } 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 getInitialFormValues = 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 renderPluginConfigForm = useCallback((): JSX.Element => { const pluginInputs = getPluginInputsFromSpec(get(plugin, 'spec', '') as string) as PluginInputs const allPluginInputs = insertNameFieldToPluginInputs(pluginInputs) 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')} )} initialValues={getInitialFormValues(pluginInputs)} onSubmit={(formData: PluginForm) => { onPluginAddUpdate?.(false, constructPayloadForYAMLInsertion(formData, plugin)) }}> {category === PluginCategory.Harness ? ( } /> } /> } /> ) : Object.keys(pluginInputs).length > 0 ? ( {Object.keys(allPluginInputs).map((field: string) => { return renderPluginFormField({ name: field, properties: get(allPluginInputs, field) }) })} ) : ( <> )}