diff --git a/web/src/components/MultiList/MultiList.module.scss b/web/src/components/MultiList/MultiList.module.scss new file mode 100644 index 000000000..711353972 --- /dev/null +++ b/web/src/components/MultiList/MultiList.module.scss @@ -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; +} diff --git a/web/src/components/MultiList/MultiList.module.scss.d.ts b/web/src/components/MultiList/MultiList.module.scss.d.ts new file mode 100644 index 000000000..4447e54b9 --- /dev/null +++ b/web/src/components/MultiList/MultiList.module.scss.d.ts @@ -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 diff --git a/web/src/components/MultiList/MultiList.tsx b/web/src/components/MultiList/MultiList.tsx new file mode 100644 index 000000000..c232e214a --- /dev/null +++ b/web/src/components/MultiList/MultiList.tsx @@ -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 +} + +interface MultiListProps { + name: string + label: string + readOnly?: boolean +} + +/* Allows user to create following structure: +: + - , + - , + ... + */ +export const MultiList = ({ name, label, readOnly, formik }: MultiListConnectedProps): JSX.Element => { + const { getString } = useStrings() + const [valueMap, setValueMap] = useState>(new Map([])) + /* + : , + : , + ... + */ + const counter = useRef(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) => { + const rowKeyToAdd = getFieldName(counter.current) + if (!existingValueMap.has(rowKeyToAdd)) { + const existingValueMapClone = new Map(existingValueMap) + existingValueMapClone.set(rowKeyToAdd, '') /* Add key , , ... */ + 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) => { + 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 handleAddItemToRow = useCallback((rowKey: string, insertedValue: string): void => { + setValueMap((existingValueMap: Map) => { + 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 ( + + + { + const value = (event.target as HTMLInputElement).value + debouncedAddItemToList(rowKey, value) + }} + /> + + { + event.preventDefault() + handleRemoveRowFromList(rowKey) + }} + /> + + ) + }, []) + + const renderRows = useCallback((): React.ReactElement => { + const rows: React.ReactElement[] = [] + valueMap.forEach((_value: string, key: string) => { + rows.push(renderRow(key)) + }) + return {rows} + }, [valueMap]) + + return ( + + + {label} + {valueMap.size > 0 && {renderRows()}} + +