From c5e5d33e4d97f26e451c4a06a578bbe58c42b13c Mon Sep 17 00:00:00 2001 From: "vardan.bansal@harness.io vardan" Date: Fri, 29 Sep 2023 18:48:41 +0000 Subject: [PATCH] Added Multimap (#633) --- web/src/components/MultiList/MultiList.tsx | 4 +- .../components/MultiMap/MultiMap.module.scss | 31 ++ .../MultiMap/MultiMap.module.scss.d.ts | 21 ++ web/src/components/MultiMap/MultiMap.tsx | 292 ++++++++++++++++++ .../components/PluginsPanel/PluginsPanel.tsx | 14 +- web/src/framework/strings/stringTypes.ts | 2 + web/src/i18n/strings.en.yaml | 2 + 7 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 web/src/components/MultiMap/MultiMap.module.scss create mode 100644 web/src/components/MultiMap/MultiMap.module.scss.d.ts create mode 100644 web/src/components/MultiMap/MultiMap.tsx diff --git a/web/src/components/MultiList/MultiList.tsx b/web/src/components/MultiList/MultiList.tsx index c232e214a..d9f8f7aa7 100644 --- a/web/src/components/MultiList/MultiList.tsx +++ b/web/src/components/MultiList/MultiList.tsx @@ -39,7 +39,7 @@ interface MultiListProps { - , - , ... - */ +*/ export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedProps): JSX.Element => { const { getString } = useStrings() const [valueMap, setValueMap] = useState>(new Map([])) @@ -51,7 +51,7 @@ export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedP const counter = useRef(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 { diff --git a/web/src/components/MultiMap/MultiMap.module.scss b/web/src/components/MultiMap/MultiMap.module.scss new file mode 100644 index 000000000..ed9a8eb3f --- /dev/null +++ b/web/src/components/MultiMap/MultiMap.module.scss @@ -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; + } + } +} diff --git a/web/src/components/MultiMap/MultiMap.module.scss.d.ts b/web/src/components/MultiMap/MultiMap.module.scss.d.ts new file mode 100644 index 000000000..fbb522d48 --- /dev/null +++ b/web/src/components/MultiMap/MultiMap.module.scss.d.ts @@ -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 diff --git a/web/src/components/MultiMap/MultiMap.tsx b/web/src/components/MultiMap/MultiMap.tsx new file mode 100644 index 000000000..90f6d623e --- /dev/null +++ b/web/src/components/MultiMap/MultiMap.tsx @@ -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 +} + +interface MultiMapProps { + name: string + label: string + readOnly?: boolean +} + +/* Allows user to create following structure: +: + : , + : , + ... +*/ + +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>(new Map([])) + const [formErrors, setFormErrors] = useState>(new Map([])) + /* + : {key: , value: }, + : {key: , value: }, + ... + */ + const counter = useRef(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: , value: }, + {key: , value: } + ] + to + { + : , + : + } + */ + 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) => { + const rowKeyToAdd = getFieldName(counter.current) + if (!existingValueMap.has(rowKeyToAdd)) { + const existingValueMapClone = new Map(existingValueMap) + /* Add key with default kv pair + : {key: '', value: ''}, + : {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) => { + if (existingValueMap.has(removedRowKey)) { + const existingValueMapClone = new Map(existingValueMap) + existingValueMapClone.delete(removedRowKey) + return existingValueMapClone + } + return existingValueMap + }) + /* remove , , ... 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) => { + 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) => { + 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 ( + + + + + { + const value = (event.target as HTMLInputElement).value + debouncedAddItemToList({ rowIdentifier, insertedValue: value, property: KVPairProperty.KEY }) + }} + /> + + + { + const value = (event.target as HTMLInputElement).value + debouncedAddItemToList({ rowIdentifier, insertedValue: value, property: KVPairProperty.VALUE }) + }} + /> + + + { + event.preventDefault() + handleRemoveRowFromList(rowIdentifier) + }} + /> + + {rowValidationError && ( + + {rowValidationError} + + )} + + ) + }, + [formErrors] + ) + + const renderMap = useCallback((): React.ReactElement => { + return ( + + + + {getString('key')} + + + {getString('value')} + + + {renderRows()} + + ) + }, [rowValues, formErrors]) + + const renderRows = useCallback((): React.ReactElement => { + const rows: React.ReactElement[] = [] + rowValues.forEach((_value: KVPair, key: string) => { + rows.push(renderRow(key)) + }) + return {rows} + }, [rowValues, formErrors]) + + return ( + + + {label} + {rowValues.size > 0 && {renderMap()}} + +