Added Multimap (#633)
This commit is contained in:
parent
560fb961bf
commit
c5e5d33e4d
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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)
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue