Merge branch 'add-list' of _OKE5H2PQKOUfzFFDuD4FA/default/CODE/gitness (#626)

This commit is contained in:
vardan.bansal@harness.io vardan 2023-09-28 21:28:01 +00:00 committed by Harness
commit 032a7a89c8
6 changed files with 431 additions and 160 deletions

View File

@ -0,0 +1,23 @@
/*
* 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.
*/
.addBtn {
width: fit-content;
}
.deleteRowBtn {
cursor: pointer;
}

View File

@ -0,0 +1,20 @@
/*
* 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.
*/
/* eslint-disable */
// This is an auto-generated file
export declare const addBtn: string
export declare const deleteRowBtn: string

View File

@ -0,0 +1,167 @@
/*
* 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 { debounce, has, omit } from 'lodash'
import { FormikContextType, connect } from 'formik'
import { Layout, Text, FormInput, Button, ButtonVariation, ButtonSize, Container } from '@harnessio/uicore'
import { FontVariation } from '@harnessio/design-system'
import { Icon } from '@harnessio/icons'
import { useStrings } from 'framework/strings'
import css from './MultiList.module.scss'
interface MultiListConnectedProps extends MultiListProps {
formik?: FormikContextType<any>
}
interface MultiListProps {
name: string
label: string
readOnly?: boolean
}
/* Allows user to create following structure:
<field-name>:
- <field-value-1>,
- <field-value-2>,
...
*/
export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedProps): JSX.Element => {
const { getString } = useStrings()
const [valueMap, setValueMap] = useState<Map<string, string>>(new Map<string, string>([]))
/*
<field-name-1>: <field-value-1>,
<field-name-2>: <field-value-2>,
...
*/
const counter = useRef<number>(0)
useEffect(() => {
const values = Array.from(valueMap.values())
if (values.length > 0) {
formik?.setFieldValue(name, values)
} else {
cleanupField()
}
}, [valueMap])
const cleanupField = useCallback((): void => {
formik?.setValues(omit({ ...formik?.values }, name))
}, [formik?.values])
const getFieldName = useCallback(
(index: number): string => {
return `${name}-${index}`
},
[name]
)
const handleAddRowToList = useCallback((): void => {
setValueMap((existingValueMap: Map<string, string>) => {
const rowKeyToAdd = getFieldName(counter.current)
if (!existingValueMap.has(rowKeyToAdd)) {
const existingValueMapClone = new Map(existingValueMap)
existingValueMapClone.set(rowKeyToAdd, '') /* Add key <field-name-1>, <field-name-2>, ... */
counter.current++ /* this counter always increases, even if a row is removed. This ensures no key collision in the existing value map. */
return existingValueMapClone
}
return existingValueMap
})
}, [])
const handleRemoveRowFromList = useCallback((removedRowKey: string): void => {
setValueMap((existingValueMap: Map<string, string>) => {
if (existingValueMap.has(removedRowKey)) {
const existingValueMapClone = new Map(existingValueMap)
existingValueMapClone.delete(removedRowKey)
return existingValueMapClone
}
return existingValueMap
})
/* remove <field-name-1>, <field-name-2>, ... from formik values, if exist */
if (removedRowKey && has(formik?.values, removedRowKey)) {
formik?.setValues(omit({ ...formik?.values }, removedRowKey))
}
}, [])
const handleAddItemToRow = useCallback((rowKey: string, insertedValue: string): void => {
setValueMap((existingValueMap: Map<string, string>) => {
if (existingValueMap.has(rowKey)) {
const existingValueMapClone = new Map(existingValueMap)
existingValueMapClone.set(rowKey, insertedValue)
return existingValueMapClone
}
return existingValueMap
})
}, [])
const debouncedAddItemToList = useCallback(debounce(handleAddItemToRow, 500), [handleAddItemToRow])
const renderRow = useCallback((rowKey: string): React.ReactElement => {
return (
<Layout.Horizontal margin={{ bottom: 'none' }} flex={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Container width="90%">
<FormInput.Text
name={rowKey}
disabled={readOnly}
onChange={event => {
const value = (event.target as HTMLInputElement).value
debouncedAddItemToList(rowKey, value)
}}
/>
</Container>
<Icon
name="code-delete"
size={25}
padding={{ bottom: 'medium' }}
className={css.deleteRowBtn}
onClick={event => {
event.preventDefault()
handleRemoveRowFromList(rowKey)
}}
/>
</Layout.Horizontal>
)
}, [])
const renderRows = useCallback((): React.ReactElement => {
const rows: React.ReactElement[] = []
valueMap.forEach((_value: string, key: string) => {
rows.push(renderRow(key))
})
return <Layout.Vertical>{rows}</Layout.Vertical>
}, [valueMap])
return (
<Layout.Vertical spacing="small">
<Layout.Vertical>
<Text font={{ variation: FontVariation.FORM_LABEL }}>{label}</Text>
{valueMap.size > 0 && <Container padding={{ top: 'small' }}>{renderRows()}</Container>}
</Layout.Vertical>
<Button
text={getString('addLabel')}
rightIcon="plus"
variation={ButtonVariation.PRIMARY}
size={ButtonSize.SMALL}
className={css.addBtn}
onClick={handleAddRowToList}
/>
</Layout.Vertical>
)
}
export default connect(MultiList)

View File

@ -68,6 +68,10 @@
.formFields { .formFields {
height: calc(100% - var(--spacing-xlarge)); height: calc(100% - var(--spacing-xlarge));
overflow-y: scroll; overflow-y: scroll;
.toggle {
margin-left: var(--spacing-1) !important;
}
} }
.search { .search {

View File

@ -29,3 +29,4 @@ export declare const pluginDetailsPanel: string
export declare const pluginIcon: string export declare const pluginIcon: string
export declare const plugins: string export declare const plugins: string
export declare const search: string export declare const search: string
export declare const toggle: string

View File

@ -14,10 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Formik } from 'formik' import { Formik, FormikContextType } from 'formik'
import { parse } from 'yaml' import { parse } from 'yaml'
import { capitalize, get, has, omit, set } from 'lodash-es' import { capitalize, get, has, omit, pick, set } from 'lodash-es'
import { Classes, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core' import { Classes, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core'
import { Color, FontVariation } from '@harnessio/design-system' import { Color, FontVariation } from '@harnessio/design-system'
import { Icon, IconProps } from '@harnessio/icons' import { Icon, IconProps } from '@harnessio/icons'
@ -36,6 +36,7 @@ import {
} from '@harnessio/uicore' } from '@harnessio/uicore'
import type { TypesPlugin } from 'services/code' import type { TypesPlugin } from 'services/code'
import { useStrings } from 'framework/strings' import { useStrings } from 'framework/strings'
import { MultiList } from 'components/MultiList/MultiList'
import css from './PluginsPanel.module.scss' import css from './PluginsPanel.module.scss'
@ -47,8 +48,16 @@ export interface PluginForm {
[key: string]: string | boolean | object [key: string]: string | boolean | object
} }
enum ValueType {
STRING = 'string',
BOOLEAN = 'boolean',
NUMBER = 'number',
ARRAY = 'array',
OBJECT = 'object'
}
interface PluginInput { interface PluginInput {
type: 'string' type: ValueType
description?: string description?: string
default?: string default?: string
options?: { isExtended?: boolean } options?: { isExtended?: boolean }
@ -114,6 +123,7 @@ export const PluginsPanel = ({ onPluginAddUpdate }: PluginsPanelInterface): JSX.
const [plugins, setPlugins] = useState<TypesPlugin[]>([]) const [plugins, setPlugins] = useState<TypesPlugin[]>([])
const [query, setQuery] = useState<string>('') const [query, setQuery] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const formikRef = useRef<FormikContextType<PluginForm>>()
const PluginCategories: PluginCategoryInterface[] = [ const PluginCategories: PluginCategoryInterface[] = [
{ {
@ -315,20 +325,52 @@ export const PluginsPanel = ({ onPluginAddUpdate }: PluginsPanelInterface): JSX.
const renderPluginFormField = ({ name, properties }: { name: string; properties: PluginInput }): JSX.Element => { const renderPluginFormField = ({ name, properties }: { name: string; properties: PluginInput }): JSX.Element => {
const { type, options } = properties const { type, options } = properties
const { isExtended } = options || {}
const WrapperComponent = isExtended ? FormInput.TextArea : FormInput.Text switch (type) {
return type === 'string' ? ( case ValueType.STRING: {
<WrapperComponent const { isExtended } = options || {}
name={name} const WrapperComponent = isExtended ? FormInput.TextArea : FormInput.Text
label={generateLabelForPluginField({ name, properties })} return (
style={{ width: '100%' }} <WrapperComponent
key={name} name={name}
/> label={generateLabelForPluginField({ name, properties })}
) : ( style={{ width: '100%' }}
<></> key={name}
) />
)
}
case ValueType.BOOLEAN:
return (
<Container className={css.toggle}>
<FormInput.Toggle
name={name}
label={generateLabelForPluginField({ name, properties }) as string}
style={{ width: '100%' }}
key={name}
/>
</Container>
)
case ValueType.ARRAY:
return (
<Container margin={{ bottom: 'large' }}>
<MultiList
name={name}
label={generateLabelForPluginField({ name, properties }) as string}
formik={formikRef.current}
/>
</Container>
)
default:
return <></>
}
} }
/* Ensures no junk/unrecognized form values are set in the YAML */
const sanitizeFormData = useCallback((existingFormData: PluginForm, pluginInputs: PluginInputs): PluginForm => {
return pick(existingFormData, Object.keys(pluginInputs))
}, [])
const constructPayloadForYAMLInsertion = (pluginFormData: PluginForm, pluginMetadata?: TypesPlugin): PluginForm => { const constructPayloadForYAMLInsertion = (pluginFormData: PluginForm, pluginMetadata?: TypesPlugin): PluginForm => {
const { name, container = {} } = pluginFormData const { name, container = {} } = pluginFormData
let payload = { ...PluginInsertionTemplate } let payload = { ...PluginInsertionTemplate }
@ -422,151 +464,165 @@ export const PluginsPanel = ({ onPluginAddUpdate }: PluginsPanelInterface): JSX.
<Formik<PluginForm> <Formik<PluginForm>
initialValues={getInitialFormValues(pluginInputs)} initialValues={getInitialFormValues(pluginInputs)}
onSubmit={(formData: PluginForm) => { onSubmit={(formData: PluginForm) => {
onPluginAddUpdate?.(false, constructPayloadForYAMLInsertion(formData, plugin)) onPluginAddUpdate?.(
}}> false,
<FormikForm height="100%" flex={{ justifyContent: 'space-between', alignItems: 'baseline' }}> constructPayloadForYAMLInsertion(sanitizeFormData(formData, pluginInputs), plugin)
<Layout.Vertical flex={{ alignItems: 'flex-start' }} height="inherit" spacing="medium"> )
<Layout.Vertical }}
width="100%" validate={(formData: PluginForm) => console.log(formData)}>
className={css.formFields} {formik => {
spacing="xsmall" formikRef.current = formik
flex={{ justifyContent: 'space-between' }}> return (
{category === PluginCategory.Harness ? ( <FormikForm height="100%" flex={{ justifyContent: 'space-between', alignItems: 'baseline' }}>
<Layout.Vertical width="inherit"> <Layout.Vertical flex={{ alignItems: 'flex-start' }} height="inherit" spacing="medium">
<FormInput.Text name={'name'} label={getString('name')} style={{ width: '100%' }} key={'name'} /> <Layout.Vertical
<FormInput.TextArea width="100%"
name={'script'} className={css.formFields}
label={getString('pluginsPanel.run.script')} spacing="xsmall"
style={{ width: '100%' }} flex={{ justifyContent: 'space-between' }}>
key={'script'} {category === PluginCategory.Harness ? (
/> <Layout.Vertical width="inherit">
<FormInput.Select <FormInput.Text
name={'shell'} name={'name'}
label={getString('pluginsPanel.run.shell')} label={getString('name')}
style={{ width: '100%' }} style={{ width: '100%' }}
key={'shell'} key={'name'}
items={[ />
{ label: getString('pluginsPanel.run.sh'), value: 'sh' }, <FormInput.TextArea
{ label: getString('pluginsPanel.run.bash'), value: 'bash' }, name={'script'}
{ label: getString('pluginsPanel.run.powershell'), value: 'powershell' }, label={getString('pluginsPanel.run.script')}
{ label: getString('pluginsPanel.run.pwsh'), value: 'pwsh' } style={{ width: '100%' }}
]} key={'script'}
/> />
<Accordion activeId=""> <FormInput.Select
<Accordion.Panel name={'shell'}
id="container" label={getString('pluginsPanel.run.shell')}
summary="Container" style={{ width: '100%' }}
details={ key={'shell'}
<Layout.Vertical className={css.indent}> items={[
<FormInput.Text { label: getString('pluginsPanel.run.sh'), value: 'sh' },
name={'container.image'} { label: getString('pluginsPanel.run.bash'), value: 'bash' },
label={getString('pluginsPanel.run.image')} { label: getString('pluginsPanel.run.powershell'), value: 'powershell' },
style={{ width: '100%' }} { label: getString('pluginsPanel.run.pwsh'), value: 'pwsh' }
key={'container.image'} ]}
/> />
<FormInput.Select <Accordion activeId="">
name={'container.pull'} <Accordion.Panel
label={getString('pluginsPanel.run.pull')} id="container"
style={{ width: '100%' }} summary="Container"
key={'container.pull'} details={
items={[ <Layout.Vertical className={css.indent}>
{ label: getString('pluginsPanel.run.always'), value: 'always' }, <FormInput.Text
{ label: getString('pluginsPanel.run.never'), value: 'never' }, name={'container.image'}
{ label: getString('pluginsPanel.run.ifNotExists'), value: 'if-not-exists' } label={getString('pluginsPanel.run.image')}
]} style={{ width: '100%' }}
/> key={'container.image'}
<FormInput.Text />
name={'container.entrypoint'} <FormInput.Select
label={getString('pluginsPanel.run.entrypoint')} name={'container.pull'}
style={{ width: '100%' }} label={getString('pluginsPanel.run.pull')}
key={'container.entrypoint'} style={{ width: '100%' }}
/> key={'container.pull'}
<FormInput.Text items={[
name={'container.network'} { label: getString('pluginsPanel.run.always'), value: 'always' },
label={getString('pluginsPanel.run.network')} { label: getString('pluginsPanel.run.never'), value: 'never' },
style={{ width: '100%' }} { label: getString('pluginsPanel.run.ifNotExists'), value: 'if-not-exists' }
key={'container.network'} ]}
/> />
<FormInput.Text <FormInput.Text
name={'container.networkMode'} name={'container.entrypoint'}
label={getString('pluginsPanel.run.networkMode')} label={getString('pluginsPanel.run.entrypoint')}
style={{ width: '100%' }} style={{ width: '100%' }}
key={'container.networkMode'} key={'container.entrypoint'}
/> />
<FormInput.Toggle <FormInput.Text
name={'container.privileged'} name={'container.network'}
label={getString('pluginsPanel.run.privileged')} label={getString('pluginsPanel.run.network')}
style={{ width: '100%' }} style={{ width: '100%' }}
key={'container.privileged'} key={'container.network'}
/> />
<FormInput.Text <FormInput.Text
name={'container.user'} name={'container.networkMode'}
label={getString('user')} label={getString('pluginsPanel.run.networkMode')}
style={{ width: '100%' }} style={{ width: '100%' }}
key={'container.user'} key={'container.networkMode'}
/> />
<Accordion activeId=""> <FormInput.Toggle
<Accordion.Panel name={'container.privileged'}
id="container.credentials" label={getString('pluginsPanel.run.privileged')}
summary={getString('pluginsPanel.run.credentials')} style={{ width: '100%' }}
details={ key={'container.privileged'}
<Layout.Vertical className={css.indent}> />
<FormInput.Text <FormInput.Text
name={'container.credentials.username'} name={'container.user'}
label={getString('pluginsPanel.run.username')} label={getString('user')}
style={{ width: '100%' }} style={{ width: '100%' }}
key={'container.credentials.username'} key={'container.user'}
/> />
<FormInput.Text <Accordion activeId="">
name={'container.credentials.password'} <Accordion.Panel
label={getString('pluginsPanel.run.password')} id="container.credentials"
style={{ width: '100%' }} summary={getString('pluginsPanel.run.credentials')}
key={'container.credentials.password'} details={
/> <Layout.Vertical className={css.indent}>
</Layout.Vertical> <FormInput.Text
} name={'container.credentials.username'}
/> label={getString('pluginsPanel.run.username')}
</Accordion> style={{ width: '100%' }}
</Layout.Vertical> key={'container.credentials.username'}
} />
/> <FormInput.Text
<Accordion.Panel name={'container.credentials.password'}
id="mount" label={getString('pluginsPanel.run.password')}
summary="Mount" style={{ width: '100%' }}
details={ key={'container.credentials.password'}
<Layout.Vertical className={css.indent}> />
<FormInput.Text </Layout.Vertical>
name={'mount.name'} }
label={getString('name')} />
style={{ width: '100%' }} </Accordion>
key={'mount.name'} </Layout.Vertical>
/> }
<FormInput.Text />
name={'mount.path'} <Accordion.Panel
label={getString('pluginsPanel.run.path')} id="mount"
style={{ width: '100%' }} summary="Mount"
key={'mount.path'} details={
/> <Layout.Vertical className={css.indent}>
</Layout.Vertical> <FormInput.Text
} name={'mount.name'}
/> label={getString('name')}
</Accordion> style={{ width: '100%' }}
key={'mount.name'}
/>
<FormInput.Text
name={'mount.path'}
label={getString('pluginsPanel.run.path')}
style={{ width: '100%' }}
key={'mount.path'}
/>
</Layout.Vertical>
}
/>
</Accordion>
</Layout.Vertical>
) : Object.keys(pluginInputs).length > 0 ? (
<Layout.Vertical width="inherit">
{Object.keys(allPluginInputs).map((field: string) => {
return renderPluginFormField({ name: field, properties: get(allPluginInputs, field) })
})}
</Layout.Vertical>
) : (
<></>
)}
</Layout.Vertical> </Layout.Vertical>
) : Object.keys(pluginInputs).length > 0 ? ( <Container margin={{ top: 'small', bottom: 'small' }}>
<Layout.Vertical width="inherit"> <Button variation={ButtonVariation.PRIMARY} text={getString('addLabel')} type="submit" />
{Object.keys(allPluginInputs).map((field: string) => { </Container>
return renderPluginFormField({ name: field, properties: get(allPluginInputs, field) }) </Layout.Vertical>
})} </FormikForm>
</Layout.Vertical> )
) : ( }}
<></>
)}
</Layout.Vertical>
<Container margin={{ top: 'small', bottom: 'small' }}>
<Button variation={ButtonVariation.PRIMARY} text={getString('addLabel')} type="submit" />
</Container>
</Layout.Vertical>
</FormikForm>
</Formik> </Formik>
</Container> </Container>
</Layout.Vertical> </Layout.Vertical>