Added Multimap (#633)

This commit is contained in:
vardan.bansal@harness.io vardan 2023-09-29 18:48:41 +00:00 committed by Harness
parent 560fb961bf
commit c5e5d33e4d
7 changed files with 362 additions and 4 deletions

View File

@ -39,7 +39,7 @@ interface MultiListProps {
- <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>([]))
@ -51,7 +51,7 @@ export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedP
const counter = useRef<number>(0)
useEffect(() => {
const values = Array.from(valueMap.values())
const values = Array.from(valueMap.values() || []).filter((value: string) => !!value)
if (values.length > 0) {
formik?.setFieldValue(name, values)
} else {

View File

@ -0,0 +1,31 @@
/*
* 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;
}
.rowError {
:global {
.bp3-form-group {
margin-bottom: 0 !important;
}
}
}

View File

@ -0,0 +1,21 @@
/*
* 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
export declare const rowError: string

View File

@ -0,0 +1,292 @@
/*
* 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 cx from 'classnames'
import { debounce, has, omit, set } from 'lodash'
import { FormikContextType, connect } from 'formik'
import { Layout, Text, FormInput, Button, ButtonVariation, ButtonSize, Container } from '@harnessio/uicore'
import { Color, FontVariation } from '@harnessio/design-system'
import { Icon } from '@harnessio/icons'
import { useStrings } from 'framework/strings'
import css from './MultiMap.module.scss'
interface MultiMapConnectedProps extends MultiMapProps {
formik?: FormikContextType<any>
}
interface MultiMapProps {
name: string
label: string
readOnly?: boolean
}
/* Allows user to create following structure:
<field-name>:
<field-name-1> : <field-value-1>,
<field-name-2> : <field-value-2>,
...
*/
interface KVPair {
key: string
value: string
}
const DefaultKVPair: KVPair = {
key: '',
value: ''
}
enum KVPairProperty {
KEY = 'key',
VALUE = 'value'
}
export const MultiMap = ({ name, label, readOnly, formik }: MultiMapConnectedProps): JSX.Element => {
const { getString } = useStrings()
const [rowValues, setRowValues] = useState<Map<string, KVPair>>(new Map<string, KVPair>([]))
const [formErrors, setFormErrors] = useState<Map<string, string>>(new Map<string, string>([]))
/*
<field-name-1>: {key: <field-name-1-key>, value: <field-name-1-value>},
<field-name-2>: {key: <field-name-2-key>, value: <field-name-2-value>},
...
*/
const counter = useRef<number>(0)
useEffect(() => {
const values = Array.from(rowValues.values()).filter((value: KVPair) => !!value.key && !!value.value)
if (values.length > 0) {
formik?.setFieldValue(name, createKVMap(values))
} else {
cleanupField()
}
}, [rowValues])
useEffect(() => {
rowValues.forEach((value: KVPair, rowIdentifier: string) => {
validateEntry({ rowIdentifier, kvPair: value })
})
}, [rowValues])
/*
Convert
[
{key: <field-name-1-key>, value: <field-name-1-value>},
{key: <field-name-2-key>, value: <field-name-2-value>}
]
to
{
<field-name-1-key>: <field-name-1-value>,
<field-name-2-key>: <field-name-2-value>
}
*/
const createKVMap = useCallback((values: KVPair[]): { [key: string]: string } => {
const map: { [key: string]: string } = values.reduce(function (map, obj: KVPair) {
set(map, obj.key, obj.value)
return map
}, {})
return map
}, [])
const cleanupField = useCallback((): void => {
formik?.setValues(omit({ ...formik?.values }, name))
}, [formik?.values])
const getFieldName = useCallback(
(index: number): string => {
return `${name}-${index}`
},
[name]
)
const getFormikNameForRowKey = useCallback((rowIdentifier: string): string => {
return `${rowIdentifier}-key`
}, [])
const handleAddRowToList = useCallback((): void => {
setRowValues((existingValueMap: Map<string, KVPair>) => {
const rowKeyToAdd = getFieldName(counter.current)
if (!existingValueMap.has(rowKeyToAdd)) {
const existingValueMapClone = new Map(existingValueMap)
/* Add key with default kv pair
<field-name-1> : {key: '', value: ''},
<field-name-2> : {key: '', value: ''},
...
*/
existingValueMapClone.set(rowKeyToAdd, DefaultKVPair)
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 => {
setRowValues((existingValueMap: Map<string, KVPair>) => {
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 validateEntry = useCallback(({ rowIdentifier, kvPair }: { rowIdentifier: string; kvPair: KVPair }) => {
setFormErrors((existingFormErrors: Map<string, string>) => {
const fieldNameKey = getFormikNameForRowKey(rowIdentifier)
const existingFormErrorsClone = new Map(existingFormErrors)
if (kvPair.value && !kvPair.key) {
existingFormErrorsClone.set(fieldNameKey, kvPair.key ? '' : getString('validation.key'))
} else {
existingFormErrorsClone.set(fieldNameKey, '')
}
return existingFormErrorsClone
})
}, [])
const handleAddItemToRow = useCallback(
({
rowIdentifier,
insertedValue,
property
}: {
rowIdentifier: string
insertedValue: string
property: KVPairProperty
}): void => {
setRowValues((existingValueMap: Map<string, KVPair>) => {
if (existingValueMap.has(rowIdentifier)) {
const existingValueMapClone = new Map(existingValueMap)
const existingPair = existingValueMapClone.get(rowIdentifier)
if (existingPair) {
if (property === KVPairProperty.KEY) {
existingValueMapClone.set(rowIdentifier, { key: insertedValue, value: existingPair.value })
} else if (property === KVPairProperty.VALUE) {
existingValueMapClone.set(rowIdentifier, { key: existingPair.key, value: insertedValue })
}
}
return existingValueMapClone
}
return existingValueMap
})
},
[]
)
const debouncedAddItemToList = useCallback(debounce(handleAddItemToRow, 500), [handleAddItemToRow])
const renderRow = useCallback(
(rowIdentifier: string): React.ReactElement => {
const rowValidationError = formErrors.get(getFormikNameForRowKey(rowIdentifier))
return (
<Layout.Vertical spacing="xsmall">
<Layout.Horizontal
margin={{ bottom: 'none' }}
flex={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Layout.Horizontal width="90%" flex={{ justifyContent: 'flex-start' }} spacing="medium">
<Container width="50%" className={cx({ [css.rowError]: rowValidationError })}>
<FormInput.Text
name={getFormikNameForRowKey(rowIdentifier)}
disabled={readOnly}
onChange={event => {
const value = (event.target as HTMLInputElement).value
debouncedAddItemToList({ rowIdentifier, insertedValue: value, property: KVPairProperty.KEY })
}}
/>
</Container>
<Container width="50%" className={cx({ [css.rowError]: rowValidationError })}>
<FormInput.Text
name={`${rowIdentifier}-value`}
disabled={readOnly}
onChange={event => {
const value = (event.target as HTMLInputElement).value
debouncedAddItemToList({ rowIdentifier, insertedValue: value, property: KVPairProperty.VALUE })
}}
/>
</Container>
</Layout.Horizontal>
<Icon
name="code-delete"
size={25}
padding={rowValidationError ? {} : { bottom: 'medium' }}
className={css.deleteRowBtn}
onClick={event => {
event.preventDefault()
handleRemoveRowFromList(rowIdentifier)
}}
/>
</Layout.Horizontal>
{rowValidationError && (
<Text font={{ variation: FontVariation.SMALL }} color={Color.RED_500}>
{rowValidationError}
</Text>
)}
</Layout.Vertical>
)
},
[formErrors]
)
const renderMap = useCallback((): React.ReactElement => {
return (
<Layout.Vertical width="100%">
<Layout.Horizontal width="90%" flex={{ justifyContent: 'flex-start' }} spacing="medium">
<Text width="50%" font={{ variation: FontVariation.SMALL }}>
{getString('key')}
</Text>
<Text width="50%" font={{ variation: FontVariation.SMALL }}>
{getString('value')}
</Text>
</Layout.Horizontal>
<Container padding={{ top: 'small' }}>{renderRows()}</Container>
</Layout.Vertical>
)
}, [rowValues, formErrors])
const renderRows = useCallback((): React.ReactElement => {
const rows: React.ReactElement[] = []
rowValues.forEach((_value: KVPair, key: string) => {
rows.push(renderRow(key))
})
return <Layout.Vertical>{rows}</Layout.Vertical>
}, [rowValues, formErrors])
return (
<Layout.Vertical spacing="small">
<Layout.Vertical>
<Text font={{ variation: FontVariation.FORM_LABEL }}>{label}</Text>
{rowValues.size > 0 && <Container padding={{ top: 'small' }}>{renderMap()}</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(MultiMap)

View File

@ -37,6 +37,7 @@ import {
import type { TypesPlugin } from 'services/code'
import { useStrings } from 'framework/strings'
import { MultiList } from 'components/MultiList/MultiList'
import MultiMap from 'components/MultiMap/MultiMap'
import css from './PluginsPanel.module.scss'
@ -360,6 +361,16 @@ export const PluginsPanel = ({ onPluginAddUpdate }: PluginsPanelInterface): JSX.
/>
</Container>
)
case ValueType.OBJECT:
return (
<Container margin={{ bottom: 'large' }}>
<MultiMap
name={name}
label={generateLabelForPluginField({ name, properties }) as string}
formik={formikRef.current}
/>
</Container>
)
default:
return <></>
@ -468,8 +479,7 @@ export const PluginsPanel = ({ onPluginAddUpdate }: PluginsPanelInterface): JSX.
false,
constructPayloadForYAMLInsertion(sanitizeFormData(formData, pluginInputs), plugin)
)
}}
validate={(formData: PluginForm) => console.log(formData)}>
}}>
{formik => {
formikRef.current = formik
return (

View File

@ -293,6 +293,7 @@ export interface StringsMap {
in: string
inactiveBranches: string
isRequired: string
key: string
killed: string
leaveAComment: string
license: string
@ -715,6 +716,7 @@ export interface StringsMap {
'validation.expirationDateRequired': string
'validation.gitBranchNameInvalid': string
'validation.gitTagNameInvalid': string
'validation.key': string
'validation.nameInvalid': string
'validation.nameIsRequired': string
'validation.nameLogic': string

View File

@ -130,6 +130,7 @@ validation:
nameLogic: Name must start with a letter or _ and only contain [a-zA-Z0-9-_.]
nameTooLong: Name is too long
nameTooShort: Name is too short
key: Key cannot be empty
commitMessage: Commit message
optionalExtendedDescription: Optional extended description
optional: Optional
@ -827,3 +828,4 @@ enterGithubPlaceholder: https://api.github.com/
changeRepoVis: Change repository visibility
changeRepoVisContent: Are you sure you want to make this repository {repoVis}? {repoText}
repoVisibility: Repository Visibility
key: Key