From f958c5f6e96e6a6387e8f97fe6e4c9ca2598de78 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Fri, 11 Apr 2025 15:16:51 +0530 Subject: [PATCH 01/14] refactor: KeyValueTable - refactor component to use DynamicDataTable --- .../KeyValueTable/KeyValueTable.component.tsx | 539 +++++------------- .../KeyValueTable/KeyValueTable.types.ts | 147 +++-- src/Shared/Components/KeyValueTable/index.ts | 2 +- src/Shared/Components/KeyValueTable/utils.ts | 81 +++ 4 files changed, 305 insertions(+), 464 deletions(-) create mode 100644 src/Shared/Components/KeyValueTable/utils.ts diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx index a292140df..bab6ea4d9 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx @@ -14,45 +14,40 @@ * limitations under the License. */ -import { createRef, Fragment, ReactElement, useEffect, useMemo, useRef, useState } from 'react' -import Tippy from '@tippyjs/react' -// eslint-disable-next-line import/no-extraneous-dependencies -import { followCursor } from 'tippy.js' - -import { ReactComponent as ICAdd } from '@Icons/ic-add.svg' -import { ReactComponent as ICClose } from '@Icons/ic-close.svg' -import { ReactComponent as ICCross } from '@Icons/ic-cross.svg' -import { ReactComponent as ICArrowDown } from '@Icons/ic-sort-arrow-down.svg' -import { ConditionalWrap, ResizableTagTextArea, SortingOrder, useStateFilters } from '@Common/index' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { debounce, noop } from '@Common/Helper' +import { useStateFilters } from '@Common/Hooks' import { DEFAULT_SECRET_PLACEHOLDER } from '@Shared/constants' import { stringComparatorBySortOrder } from '@Shared/Helpers' +import { DynamicDataTable, DynamicDataTableCellValidationState } from '../DynamicDataTable' import { DUPLICATE_KEYS_VALIDATION_MESSAGE, EMPTY_KEY_VALIDATION_MESSAGE } from './constants' -import { KeyValueRow, KeyValueTableProps } from './KeyValueTable.types' +import { + KeyValueTableData, + KeyValueTableDataType, + KeyValueTableInternalProps, + KeyValueTableProps, +} from './KeyValueTable.types' +import { + getEmptyRow, + getKeyValueHeaders, + getKeyValueInitialCellError, + getKeyValueInitialRows, + getKeyValueTableKeysFrequency, + getModifiedDataForOnChange, +} from './utils' import './KeyValueTable.scss' -const renderWithReadOnlyTippy = (children: ReactElement) => ( - - {children} - -) - -export const KeyValueTable = ({ - config, +export const KeyValueTable = ({ + headerLabel, + initialRows, + placeholder, maskValue, isSortable, headerComponent, onChange, - onDelete, - placeholder, isAdditionNotAllowed, readOnly, showError, @@ -61,420 +56,192 @@ export const KeyValueTable = ({ onError, validateDuplicateKeys = false, validateEmptyKeys = false, -}: KeyValueTableProps) => { - // CONSTANTS - const { headers, rows } = config - const firstHeaderKey = headers[0].key - const secondHeaderKey = headers[1].key - +}: KeyValueTableProps) => { // STATES - const [updatedRows, setUpdatedRows] = useState[]>(rows) - /** State to trigger useEffect to trigger autoFocus */ - const [newRowAdded, setNewRowAdded] = useState(false) - - const isActionDisabled = readOnly || isAdditionNotAllowed + const [rows, setRows] = useState( + getKeyValueInitialRows({ initialRows, placeholder }), + ) - /** Boolean determining if table has rows. */ - const hasRows = (!readOnly && !isAdditionNotAllowed) || !!updatedRows.length - const isFirstRowEmpty = !updatedRows[0]?.data[firstHeaderKey].value && !updatedRows[0]?.data[secondHeaderKey].value - const disableDeleteRow = updatedRows.length === 1 && isFirstRowEmpty + const [cellError, setCellError] = useState( + getKeyValueInitialCellError(rows), + ) // HOOKS - const { sortBy, sortOrder, handleSorting } = useStateFilters({ - initialSortKey: isSortable ? firstHeaderKey : null, + const { sortBy, sortOrder, handleSorting } = useStateFilters({ + initialSortKey: isSortable ? 'key' : null, }) - const keyTextAreaRef = useRef>>() - const valueTextAreaRef = useRef>>() - if (!keyTextAreaRef.current) { - keyTextAreaRef.current = updatedRows.reduce((acc, curr) => ({ ...acc, [curr.id]: createRef() }), {}) - } + const rowWithMaskedValues = useMemo(() => { + if (maskValue && Object.keys(maskValue).length) { + return rows.map((row) => ({ + ...row, + data: { + ...row.data, + key: { + ...row.data.key, + value: maskValue.key ? DEFAULT_SECRET_PLACEHOLDER : row.data.key.value, + }, + value: { + ...row.data.value, + value: maskValue.value ? DEFAULT_SECRET_PLACEHOLDER : row.data.value.value, + }, + }, + })) + } - if (!valueTextAreaRef.current) { - valueTextAreaRef.current = updatedRows.reduce((acc, curr) => ({ ...acc, [curr.id]: createRef() }), {}) - } + return rows + }, [rows, maskValue]) - const updatedRowsKeysFrequency: Record = useMemo( - () => - updatedRows.reduce( - (acc, curr) => { - const currentKey = curr.data[firstHeaderKey].value - if (currentKey) { - acc[currentKey] = (acc[currentKey] || 0) + 1 - } - return acc - }, - {} as Record, - ), - [updatedRows], + const debounceOnChange = useCallback( + debounce((modifiedRows: KeyValueTableData[]) => + typeof onChange === 'function' ? onChange(modifiedRows) : noop, + ), + [], ) + // USE-EFFECTS + useEffect(() => { + if (isSortable) { + setRows((prevRows) => { + const sortedRows = prevRows + sortedRows.sort((a, b) => + stringComparatorBySortOrder(a.data[sortBy].value, b.data[sortBy].value, sortOrder), + ) + return sortedRows + }) + } + }, [sortOrder]) + + // METHODS const validationSchema = ( value: Parameters[0], key: Parameters[1], rowId: Parameters[2], - shouldTriggerCustomValidation: boolean = true, - ) => { - if (shouldTriggerCustomValidation) { - const trimmedValue = value.trim() - - if (validateDuplicateKeys && key === firstHeaderKey && updatedRowsKeysFrequency[trimmedValue] > 1) { - return false + keysFrequency: Record = {}, + ): DynamicDataTableCellValidationState => { + const trimmedValue = value.trim() + + if (validateDuplicateKeys && key === 'key' && (keysFrequency[trimmedValue] ?? 0) > 1) { + return { + isValid: false, + errorMessages: [DUPLICATE_KEYS_VALIDATION_MESSAGE], } + } - if (validateEmptyKeys && key === firstHeaderKey && !trimmedValue) { - const isValuePresentAtRow = updatedRows.some( - ({ id, data }) => id === rowId && data[secondHeaderKey].value.trim(), - ) - if (isValuePresentAtRow) { - return false + if (validateEmptyKeys && key === 'key' && !trimmedValue) { + const isValuePresentAtRow = rows.some(({ id, data }) => id === rowId && data.value.value.trim()) + if (isValuePresentAtRow) { + return { + isValid: false, + errorMessages: [EMPTY_KEY_VALIDATION_MESSAGE], } } } if (parentValidationSchema) { - return parentValidationSchema(value, key, rowId) + const isValid = parentValidationSchema(value, key, rowId) + return { + isValid, + errorMessages: !isValid ? parentErrorMessages : [], + } } - return true + return { + isValid: true, + errorMessages: [], + } } - const checkAllRowsAreValid = (editedRows: KeyValueRow[]) => { - if (validateDuplicateKeys) { - const { isAnyKeyDuplicated } = editedRows.reduce( - (acc, curr) => { - const { keysFrequency } = acc - const currentKey = curr.data[firstHeaderKey].value.trim() - - if (currentKey) { - keysFrequency[currentKey] = (keysFrequency[currentKey] || 0) + 1 - } - - return { - isAnyKeyDuplicated: acc.isAnyKeyDuplicated || keysFrequency[currentKey] > 1, - keysFrequency, - } - }, - { isAnyKeyDuplicated: false, keysFrequency: {} as Record }, - ) + const checkAllRowsAreValid = (updatedRows: typeof rows) => { + let isValid = true - if (isAnyKeyDuplicated) { - return false - } - } - - if (validateEmptyKeys) { - const isEmptyKeyPresent = editedRows.some( - (row) => !row.data[firstHeaderKey].value.trim() && row.data[secondHeaderKey].value.trim(), + const updatedCellError = updatedRows.reduce((acc, { data, id }) => { + const keyError = validationSchema( + data.key.value, + 'key', + id, + validateDuplicateKeys ? getKeyValueTableKeysFrequency(rows) : {}, ) + const valueError = validationSchema(data.value.value, 'value', id) - if (isEmptyKeyPresent) { - return false + if (isValid && !(keyError.isValid && valueError.isValid)) { + isValid = false } - } - - // Sending custom validation as false since already checked above - const isValid = editedRows.every( - ({ data: _data, id }) => - validationSchema(_data[firstHeaderKey].value, firstHeaderKey, id, false) && - validationSchema(_data[secondHeaderKey].value, secondHeaderKey, id, false), - ) - return isValid - } + acc[id] = { + key: keyError, + value: valueError, + } - const getEmptyRow = (): KeyValueRow => { - const id = (Date.now() * Math.random()).toString(16) - const data = { - data: { - [firstHeaderKey]: { - value: '', - }, - [secondHeaderKey]: { - value: '', - }, - }, - id, - } as KeyValueRow + return acc + }, {}) - return data + return { isValid, updatedCellError } } - const handleAddNewRow = () => { - const data = getEmptyRow() - const editedRows = [data, ...updatedRows] + const setUpdatedRows = (updatedRows: typeof rows, shouldDebounceChange = false) => { + const { isValid, updatedCellError } = checkAllRowsAreValid(updatedRows) - const { id } = data + setRows(updatedRows) + setCellError(updatedCellError) - onError?.(!checkAllRowsAreValid(editedRows)) - setNewRowAdded(true) - setUpdatedRows(editedRows) + onError?.(!isValid) - keyTextAreaRef.current = { - ...(keyTextAreaRef.current || {}), - [id as string]: createRef(), - } - valueTextAreaRef.current = { - ...(valueTextAreaRef.current || {}), - [id as string]: createRef(), + if (shouldDebounceChange) { + debounceOnChange(getModifiedDataForOnChange(updatedRows)) + } else { + onChange?.(getModifiedDataForOnChange(updatedRows)) } } - useEffect(() => { - if (!isActionDisabled && !updatedRows.length) { - handleAddNewRow() - } - }, []) - - useEffect(() => { - if (isSortable) { - setUpdatedRows((prevRows) => { - const sortedRows = structuredClone(prevRows) - sortedRows.sort((a, b) => - stringComparatorBySortOrder(a.data[sortBy].value, b.data[sortBy].value, sortOrder), - ) - return sortedRows - }) - } - }, [sortOrder]) - - useEffect(() => { - const firstRow = updatedRows[0] - - if (firstRow && newRowAdded) { - setNewRowAdded(false) - const areKeyAndValueTextAreaRefsPresent = - keyTextAreaRef.current[firstRow.id].current && valueTextAreaRef.current[firstRow.id].current - - if (!firstRow.data[firstHeaderKey].value && areKeyAndValueTextAreaRefsPresent) { - valueTextAreaRef.current[firstRow.id].current.focus() - } - if (!firstRow.data[secondHeaderKey].value && areKeyAndValueTextAreaRefsPresent) { - keyTextAreaRef.current[firstRow.id].current.focus() - } - } - }, [newRowAdded]) + const onRowAdd = () => { + const newRow = getEmptyRow(placeholder) + const updatedRows = [newRow, ...rows] - // METHODS - const onSortBtnClick = () => handleSorting(sortBy) + setUpdatedRows(updatedRows) + } - const onRowDelete = (row: KeyValueRow) => () => { - const remainingRows = updatedRows.filter(({ id }) => id !== row.id) + const onRowDelete: KeyValueTableInternalProps['onRowDelete'] = (row) => { + const remainingRows = rows.filter(({ id }) => id !== row.id) if (remainingRows.length === 0 && !isAdditionNotAllowed) { - const emptyRowData = getEmptyRow() - const { id } = emptyRowData + const emptyRowData = getEmptyRow(placeholder) - setNewRowAdded(true) - onError?.(!checkAllRowsAreValid([emptyRowData])) setUpdatedRows([emptyRowData]) - - keyTextAreaRef.current = { - [id as string]: createRef(), - } - valueTextAreaRef.current = { - [id as string]: createRef(), - } - - onDelete?.(row.id) return } - onError?.(!checkAllRowsAreValid(remainingRows)) setUpdatedRows(remainingRows) - - delete keyTextAreaRef.current[row.id] - delete valueTextAreaRef.current[row.id] - - onDelete?.(row.id) } - const onRowDataEdit = (row: KeyValueRow, key: K) => (e: React.ChangeEvent) => { - const { value } = e.target - const rowData = { - ...row, - data: { - ...row.data, - [key]: { - ...row.data[key], - value, - }, - }, - } - const editedRows = updatedRows.map((_row) => (_row.id === row.id ? rowData : _row)) - onError?.(!checkAllRowsAreValid(editedRows)) - setUpdatedRows(editedRows) - } - - const onRowDataBlur = (row: KeyValueRow, key: K) => (e: React.FocusEvent) => { - const { value } = e.target - - onChange?.(row.id, key, value) - onError?.(!checkAllRowsAreValid(updatedRows)) - } - - const renderFirstHeader = (key: K, label: string, className: string) => ( -
- {isSortable ? ( - - ) : ( -
- {label} - {!!headerComponent && headerComponent} -
- )} - - -
- ) - - const renderErrorMessage = (errorMessage: string) => ( -
- -

{errorMessage}

-
- ) - - const renderErrorMessages = ( - value: Parameters[0], - key: Parameters[1], - rowId: KeyValueRow['id'], - ) => { - const showErrorMessages = showError && !validationSchema(value, key, rowId) - if (!showErrorMessages) { - return null + const onRowEdit: KeyValueTableInternalProps['onRowEdit'] = (row, headerKey, value) => { + const updatedRows = rows + const rowIndex = rows.findIndex(({ id }) => row.id === id) + const selectedRow = rows[rowIndex] + if (selectedRow) { + selectedRow.data[headerKey].value = value + updatedRows[rowIndex] = selectedRow } - return ( -
- {validateDuplicateKeys && renderErrorMessage(DUPLICATE_KEYS_VALIDATION_MESSAGE)} - {validateEmptyKeys && renderErrorMessage(EMPTY_KEY_VALIDATION_MESSAGE)} - {parentErrorMessages.map((error) => renderErrorMessage(error))} -
- ) + setUpdatedRows(updatedRows, true) } return ( - <> -
-
- {/* HEADER */} -
- {headers.map(({ key, label, className }) => - key === firstHeaderKey ? ( - renderFirstHeader(key, label, className) - ) : ( -
- {label} - {!!headerComponent && headerComponent} -
- ), - )} -
-
-
- - {hasRows && ( -
- {!!updatedRows.length && ( -
- {updatedRows.map((row) => ( -
- {headers.map(({ key }) => ( - - -
- {maskValue?.[key] && row.data[key].value ? ( -
- {DEFAULT_SECRET_PLACEHOLDER} -
- ) : ( - <> - - {row.data[key].required && ( - - * - - )} - {renderErrorMessages(row.data[key].value, key, row.id)} - - )} -
-
-
- ))} - {!readOnly && ( - - )} -
- ))} -
- )} -
- )} - + ) } diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts b/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts index d437b514a..7b06308b5 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts @@ -14,47 +14,13 @@ * limitations under the License. */ -import { ReactNode } from 'react' +import { TooltipProps } from '@Common/Tooltip' -import { ResizableTagTextAreaProps } from '../../../Common' +import { DynamicDataTableProps } from '../DynamicDataTable' -/** - * Interface representing a key-value header. - * @template K - A string representing the key type. - */ -export interface KeyValueHeader { - /** The label of the header. */ - label: string - /** The key associated with the header. */ - key: K - /** An optional class name for the header. */ - className?: string -} - -/** - * Type representing a key-value row. - * @template K - A string representing the key type. - */ -export type KeyValueRow = { - data: { - [key in K]: Pick & { - /** An optional boolean indicating if an asterisk should be shown. */ - required?: boolean - } - } - id: string | number -} +export type KeyValueTableDataType = 'key' | 'value' -/** - * Interface representing the configuration for a key-value table. - * @template K - A string representing the key type. - */ -export interface KeyValueConfig { - /** An array containing two key-value headers. */ - headers: [KeyValueHeader, KeyValueHeader] - /** An array of key-value rows. */ - rows: KeyValueRow[] -} +export type KeyValueTableInternalProps = DynamicDataTableProps type ErrorUIProps = | { @@ -82,64 +48,91 @@ type ErrorUIProps = validateEmptyKeys?: never } -/** - * Type representing a mask for key-value pairs. - * @template K - A string representing the key type. - */ -export type KeyValueMask = { +export type KeyValueHeaderLabel = { + [key in K]: string +} + +export type KeyValueMask = { [key in K]?: boolean } -export type KeyValuePlaceholder = { +export type KeyValuePlaceholder = { [key in K]?: string } +export interface KeyValueTableRowType { + id: string | number + data: { + [key in K]: { + value: string + /** An optional boolean indicating if the cell should be marked as disabled. */ + disabled?: boolean + /** An optional boolean indicating if an asterisk should be shown. */ + required?: boolean + /** An optional tooltip to show when hovering over cell. */ + tooltip?: Partial> + } + } +} + +export interface KeyValueTableData extends Pick { + key: string + value: string +} + /** - * Interface representing the properties for a key-value table component. - * @template K - A string representing the key type. + * Props for the KeyValueTable component. */ -export type KeyValueTableProps = { - /** The configuration for the key-value table. */ - config: KeyValueConfig - /** An optional mask for the key-value pairs. */ - maskValue?: KeyValueMask - placeholder?: KeyValuePlaceholder - /** An optional boolean indicating if the table is sortable. */ - isSortable?: boolean - /** An optional React node for a custom header component. */ - headerComponent?: ReactNode - /** When true, data addition field will not be shown. */ - isAdditionNotAllowed?: boolean - /** When true, data add or update is disabled. */ - readOnly?: boolean +export type KeyValueTableProps = Pick< + DynamicDataTableProps, + 'isAdditionNotAllowed' | 'readOnly' | 'headerComponent' +> & { + /** + * The label for the table header. + */ + headerLabel: KeyValueHeaderLabel + /** + * The initial rows of the key-value table. + */ + initialRows: KeyValueTableRowType[] /** - * An optional function to handle changes in the table rows. - * @param rowId - The id of the row that changed. - * @param headerKey - The key of the header that changed. - * @param value - The value of the cell. + * An optional configuration to mask values in the table. */ - onChange?: (rowId: string | number, headerKey: K, value: string) => void + maskValue?: KeyValueMask + /** + * An optional placeholder configuration for the table columns. + */ + placeholder?: KeyValuePlaceholder + /** + * An optional boolean indicating if the `key` column is sortable. + */ + isSortable?: boolean /** - * An optional function to handle row deletions. - * @param deletedRowIndex - The index of the row that was deleted. + * A callback function triggered when the table rows change. + * + * @param data - The updated table data. */ - onDelete?: (deletedRowId: string | number) => void + onChange?: (data: KeyValueTableData[]) => void /** - * The function to use to validate the value of the cell. + * A function to validate the value of a cell. + * * @param value - The value to validate. - * @param key - The row key of the value. - * @param rowId - The id of the row. - * @returns Return true if the value is valid, otherwise false - * and set `showError` to `true` and provide errorMessages array to show error message. + * @param key - The key of the header associated with the value. + * @param rowId - The id of the row containing the value. + * @returns A boolean indicating whether the value is valid. If false, + * `showError` should be set to `true` and `errorMessages` should + * provide an array of error messages to display. */ - validationSchema?: (value: string, key: K, rowId: string | number) => boolean + validationSchema?: (value: string, key: KeyValueTableDataType, rowId: KeyValueTableRowType['id']) => boolean /** - * An array of error messages to be displayed in the cell error tooltip. + * An array of error messages to display in the cell error tooltip. */ errorMessages?: string[] /** - * A callback function called when an error occurs. - * @param errorState - The error state, true when any cell has error, otherwise false. + * A callback function triggered when an error occurs in the table. + * + * @param errorState - A boolean indicating the error state. True if any + * cell has an error, otherwise false. */ onError?: (errorState: boolean) => void } & ErrorUIProps diff --git a/src/Shared/Components/KeyValueTable/index.ts b/src/Shared/Components/KeyValueTable/index.ts index e81474570..058030a56 100644 --- a/src/Shared/Components/KeyValueTable/index.ts +++ b/src/Shared/Components/KeyValueTable/index.ts @@ -15,4 +15,4 @@ */ export * from './KeyValueTable.component' -export * from './KeyValueTable.types' +export type { KeyValueTableData, KeyValueTableProps } from './KeyValueTable.types' diff --git a/src/Shared/Components/KeyValueTable/utils.ts b/src/Shared/Components/KeyValueTable/utils.ts new file mode 100644 index 000000000..fba83b5f0 --- /dev/null +++ b/src/Shared/Components/KeyValueTable/utils.ts @@ -0,0 +1,81 @@ +import { getUniqueId } from '@Shared/Helpers' + +import { DynamicDataTableRowDataType } from '../DynamicDataTable' +import { KeyValueTableData, KeyValueTableInternalProps, KeyValueTableProps } from './KeyValueTable.types' + +export const getModifiedDataForOnChange = (rows: KeyValueTableInternalProps['rows']): KeyValueTableData[] => + rows.map(({ data, id }) => ({ id, key: data.key.value, value: data.value.value })) + +export const getEmptyRow = ( + placeholder: KeyValueTableProps['placeholder'], +): KeyValueTableInternalProps['rows'][number] => ({ + id: getUniqueId(), + data: { + key: { + type: DynamicDataTableRowDataType.TEXT, + props: { placeholder: placeholder.key }, + value: '', + }, + value: { + type: DynamicDataTableRowDataType.TEXT, + props: { placeholder: placeholder.value }, + value: '', + }, + }, +}) + +export const getKeyValueInitialRows = ({ + initialRows, + placeholder, +}: Pick): KeyValueTableInternalProps['rows'] => + initialRows.length + ? initialRows.map(({ data: { key, value }, id }) => ({ + data: { + key: { + ...key, + type: DynamicDataTableRowDataType.TEXT, + props: { placeholder: placeholder.key }, + }, + value: { + ...value, + type: DynamicDataTableRowDataType.TEXT, + props: { placeholder: placeholder.value }, + }, + }, + id, + })) + : [getEmptyRow(placeholder)] + +export const getKeyValueInitialCellError = ( + rows: KeyValueTableInternalProps['rows'], +): KeyValueTableInternalProps['cellError'] => + rows.reduce((acc, curr) => { + if (!acc[curr.id]) { + acc[curr.id] = { + key: { isValid: true, errorMessages: [] }, + value: { isValid: true, errorMessages: [] }, + } + } + + return acc + }, {}) + +export const getKeyValueHeaders = ({ + headerLabel, + isSortable, +}: Pick): KeyValueTableInternalProps['headers'] => [ + { key: 'key', label: headerLabel.key, width: '30%', isSortable }, + { key: 'value', label: headerLabel.value, width: '1fr' }, +] + +export const getKeyValueTableKeysFrequency = (rows: KeyValueTableInternalProps['rows']) => + rows.reduce( + (acc, curr) => { + const currentKey = curr.data.key.value + if (currentKey) { + acc[currentKey] = (acc[currentKey] || 0) + 1 + } + return acc + }, + {} as Record, + ) From fcf245a90adff00d92b110d01c9db7a36451420b Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Fri, 11 Apr 2025 15:50:55 +0530 Subject: [PATCH 02/14] refactor: update CMCS to use KeyValueTableData type instead of CMSecretYamlData type --- src/Shared/Components/CMCS/constants.ts | 5 ++-- src/Shared/Components/CMCS/utils.ts | 35 ++++++++++++----------- src/Shared/Components/CMCS/validations.ts | 5 ++-- src/Shared/Services/app.types.ts | 9 ++---- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/Shared/Components/CMCS/constants.ts b/src/Shared/Components/CMCS/constants.ts index d24b0012f..616ccb1e0 100644 --- a/src/Shared/Components/CMCS/constants.ts +++ b/src/Shared/Components/CMCS/constants.ts @@ -14,8 +14,9 @@ * limitations under the License. */ -import { CMSecretExternalType, CMSecretYamlData } from '@Shared/Services' +import { CMSecretExternalType } from '@Shared/Services' +import { KeyValueTableData } from '../KeyValueTable' import { ConfigMapSecretDataTypeOptionType } from './types' export const CONFIG_MAP_SECRET_YAML_PARSE_ERROR = 'Please provide valid YAML' @@ -33,7 +34,7 @@ export const configMapDataTypeOptions: ConfigMapSecretDataTypeOptionType[] = [ { value: CMSecretExternalType.KubernetesConfigMap, label: 'Kubernetes External ConfigMap' }, ] -export const CONFIG_MAP_SECRET_DEFAULT_CURRENT_DATA: CMSecretYamlData[] = [{ k: '', v: '', id: 0 }] +export const CONFIG_MAP_SECRET_DEFAULT_CURRENT_DATA: KeyValueTableData[] = [{ key: '', value: '', id: 0 }] export const configMapSecretMountDataMap = { environment: { title: 'Environment Variable', value: 'environment' }, diff --git a/src/Shared/Components/CMCS/utils.ts b/src/Shared/Components/CMCS/utils.ts index 2d5347d49..6222d34c2 100644 --- a/src/Shared/Components/CMCS/utils.ts +++ b/src/Shared/Components/CMCS/utils.ts @@ -26,7 +26,6 @@ import { CMSecretConfigData, CMSecretExternalType, CMSecretPayloadType, - CMSecretYamlData, CODE_EDITOR_RADIO_STATE, ConfigDatum, ConfigMapSecretUseFormProps, @@ -36,6 +35,7 @@ import { } from '@Shared/Services' import { hasESO, OverrideMergeStrategyType } from '@Pages/index' +import { KeyValueTableData } from '../KeyValueTable' import { getSelectPickerOptionByValue } from '../SelectPicker' import { CONFIG_MAP_SECRET_DEFAULT_CURRENT_DATA, @@ -93,7 +93,7 @@ export const getSecretDataTypeOptions = ( return isJob ? kubernetesOptions : [...kubernetesOptions, ...esoOptions, ...(isHashiOrAWS ? kesOptions : [])] } -const secureValues = (data: Record, decodeData: boolean): CMSecretYamlData[] => { +const secureValues = (data: Record, decodeData: boolean): KeyValueTableData[] => { let decodedData = data || DEFAULT_SECRET_PLACEHOLDER if (decodeData) { @@ -104,9 +104,9 @@ const secureValues = (data: Record, decodeData: boolean): CMSecr } } - return Object.keys(decodedData).map((k, id) => ({ - k, - v: typeof decodedData[k] === 'object' ? YAMLStringify(decodedData[k]) : decodedData[k], + return Object.keys(decodedData).map((key, id) => ({ + key, + value: typeof decodedData[key] === 'object' ? YAMLStringify(decodedData[key]) : decodedData[key], id, })) } @@ -150,8 +150,8 @@ const processExternalSubPathValues = ({ return '' } -export const convertKeyValuePairToYAML = (currentData: CMSecretYamlData[]) => - currentData.length ? YAMLStringify(currentData.reduce((agg, { k, v }) => ({ ...agg, [k]: v }), {})) : '' +export const convertKeyValuePairToYAML = (currentData: KeyValueTableData[]) => + currentData.length ? YAMLStringify(currentData.reduce((agg, { key, value }) => ({ ...agg, [key]: value }), {})) : '' const getSecretDataFromConfigData = ({ secretData, @@ -368,29 +368,30 @@ export const getConfigMapSecretReadOnlyValues = ({ ? [ { displayName: 'Keys', - value: currentData?.length > 0 ? currentData.map((d) => d.k).join(', ') : 'No keys available', + value: + currentData?.length > 0 ? currentData.map((d) => d.key).join(', ') : 'No keys available', key: 'keys', }, ] : []), ], - data: !mountExistingExternal ? (currentData?.[0]?.k && yaml) || esoSecretYaml || secretDataYaml : null, + data: !mountExistingExternal ? (currentData?.[0]?.key && yaml) || esoSecretYaml || secretDataYaml : null, } } -export const convertYAMLToKeyValuePair = (yaml: string): CMSecretYamlData[] => { +export const convertYAMLToKeyValuePair = (yaml: string): KeyValueTableData[] => { try { const obj = yaml && YAML.parse(yaml) if (typeof obj !== 'object') { throw new Error() } - const keyValueArray: CMSecretYamlData[] = Object.keys(obj).reduce((agg, k, id) => { - if (!k && !obj[k]) { + const keyValueArray = Object.keys(obj).reduce((agg, key, id) => { + if (!key && !obj[key]) { return CONFIG_MAP_SECRET_DEFAULT_CURRENT_DATA } - const v = obj[k] && typeof obj[k] === 'object' ? YAMLStringify(obj[k]) : obj[k].toString() + const value = obj[key] && typeof obj[key] === 'object' ? YAMLStringify(obj[key]) : obj[key].toString() - return [...agg, { k, v: v ?? '', id }] + return [...agg, { key, value: value ?? '', id }] }, []) return keyValueArray } catch { @@ -449,14 +450,14 @@ export const getConfigMapSecretPayload = ({ const isESO = isSecret && hasESO(externalType) const _currentData = yamlMode ? convertYAMLToKeyValuePair(yaml) : currentData const data = _currentData.reduce((acc, curr) => { - if (!curr.k) { + if (!curr.key) { return acc } - const value = curr.v ?? '' + const value = curr.value ?? '' return { ...acc, - [curr.k]: isSecret && externalType === '' ? btoa(value) : value, + [curr.key]: isSecret && externalType === '' ? btoa(value) : value, } }, {}) diff --git a/src/Shared/Components/CMCS/validations.ts b/src/Shared/Components/CMCS/validations.ts index f1858649a..fa76dfe12 100644 --- a/src/Shared/Components/CMCS/validations.ts +++ b/src/Shared/Components/CMCS/validations.ts @@ -19,10 +19,11 @@ import YAML from 'yaml' import { PATTERNS } from '@Common/Constants' import { YAMLStringify } from '@Common/Helper' import { UseFormValidation, UseFormValidations } from '@Shared/Hooks' -import { CMSecretExternalType, CMSecretYamlData, ConfigMapSecretUseFormProps } from '@Shared/Services' +import { CMSecretExternalType, ConfigMapSecretUseFormProps } from '@Shared/Services' import { validateCMVolumeMountPath } from '@Shared/validations' import { hasESO } from '@Pages/index' +import { KeyValueTableData } from '../KeyValueTable' import { CONFIG_MAP_SECRET_YAML_PARSE_ERROR, SECRET_TOAST_INFO } from './constants' import { getESOSecretDataFromYAML } from './utils' @@ -310,7 +311,7 @@ export const getConfigMapSecretFormValidations: UseFormValidations !!value.filter(({ k }) => !!k).length, + isValid: (value: KeyValueTableData[]) => !!value.filter(({ key }) => !!key).length, message: 'This is a required field', }, }, diff --git a/src/Shared/Services/app.types.ts b/src/Shared/Services/app.types.ts index 399b195af..e04c22c39 100644 --- a/src/Shared/Services/app.types.ts +++ b/src/Shared/Services/app.types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { KeyValueTableData } from '@Shared/Components' import { TargetPlatformsDTO } from '@Shared/types' import { OverrideMergeStrategyType } from '@Pages/Applications' @@ -241,12 +242,6 @@ export interface ConfigMapSecretDataType { isDeletable: boolean } -export interface CMSecretYamlData { - k: string - v: string - id: string | number -} - export interface ConfigMapSecretUseFormProps { name: string isSecret: boolean @@ -261,7 +256,7 @@ export interface ConfigMapSecretUseFormProps { roleARN: string yamlMode: boolean yaml: string - currentData: CMSecretYamlData[] + currentData: KeyValueTableData[] secretDataYaml: string esoSecretYaml: string hasCurrentDataErr: boolean From 0e8816c1f6b5a5874f23b7031352bf8d74de12d1 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Tue, 15 Apr 2025 11:39:05 +0530 Subject: [PATCH 03/14] fix: KeyValueTable - fix sorting logic --- .../KeyValueTable/KeyValueTable.component.tsx | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx index bab6ea4d9..3903a49bb 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { SortingOrder } from '@Common/Constants' import { debounce, noop } from '@Common/Helper' import { useStateFilters } from '@Common/Hooks' import { DEFAULT_SECRET_PLACEHOLDER } from '@Shared/constants' @@ -99,19 +100,6 @@ export const KeyValueTable = ({ [], ) - // USE-EFFECTS - useEffect(() => { - if (isSortable) { - setRows((prevRows) => { - const sortedRows = prevRows - sortedRows.sort((a, b) => - stringComparatorBySortOrder(a.data[sortBy].value, b.data[sortBy].value, sortOrder), - ) - return sortedRows - }) - } - }, [sortOrder]) - // METHODS const validationSchema = ( value: Parameters[0], @@ -226,6 +214,24 @@ export const KeyValueTable = ({ setUpdatedRows(updatedRows, true) } + const onSorting = (_sortBy: KeyValueTableDataType) => { + handleSorting(_sortBy) + + if (isSortable) { + setRows((prevRows) => { + const sortedRows = prevRows + sortedRows.sort((a, b) => + stringComparatorBySortOrder( + a.data[_sortBy].value, + b.data[_sortBy].value, + sortOrder === SortingOrder.ASC ? SortingOrder.DESC : SortingOrder.ASC, + ), + ) + return sortedRows + }) + } + } + return ( ) From a63823e79149832c645e644d80dc672c621866d0 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Mon, 21 Apr 2025 12:57:11 +0530 Subject: [PATCH 04/14] refactor: update FormType to use KeyValueTableData for args --- src/Common/CIPipeline.Types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Common/CIPipeline.Types.ts b/src/Common/CIPipeline.Types.ts index 45ab04918..8dbddd04a 100644 --- a/src/Common/CIPipeline.Types.ts +++ b/src/Common/CIPipeline.Types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DynamicDataTableCellValidationState } from '@Shared/Components' +import { DynamicDataTableCellValidationState, KeyValueTableData } from '@Shared/Components' export interface MaterialType { name: string @@ -266,7 +266,7 @@ export enum WORKFLOW_CACHE_CONFIG_ENUM { export interface FormType { name: string - args: { key: string; value: string }[] + args: KeyValueTableData[] materials: MaterialType[] gitHost: Githost webhookEvents: WebhookEvent[] From a86a48ed02d5278a47d895b1a3d732f023519c52 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Mon, 21 Apr 2025 13:01:27 +0530 Subject: [PATCH 05/14] refactor: KeyValueTable - move methods to utils and code optimization for better state management --- .../KeyValueTable/KeyValueTable.component.tsx | 181 ++++-------------- .../KeyValueTable/KeyValueTable.types.ts | 33 +++- src/Shared/Components/KeyValueTable/utils.ts | 147 ++++++++++++-- 3 files changed, 186 insertions(+), 175 deletions(-) diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx index 3903a49bb..968e3cb42 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx @@ -14,28 +14,18 @@ * limitations under the License. */ -import { useCallback, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' -import { SortingOrder } from '@Common/Constants' -import { debounce, noop } from '@Common/Helper' import { useStateFilters } from '@Common/Hooks' -import { DEFAULT_SECRET_PLACEHOLDER } from '@Shared/constants' -import { stringComparatorBySortOrder } from '@Shared/Helpers' -import { DynamicDataTable, DynamicDataTableCellValidationState } from '../DynamicDataTable' -import { DUPLICATE_KEYS_VALIDATION_MESSAGE, EMPTY_KEY_VALIDATION_MESSAGE } from './constants' -import { - KeyValueTableData, - KeyValueTableDataType, - KeyValueTableInternalProps, - KeyValueTableProps, -} from './KeyValueTable.types' +import { DynamicDataTable } from '../DynamicDataTable' +import { KeyValueTableDataType, KeyValueTableInternalProps, KeyValueTableProps } from './KeyValueTable.types' import { getEmptyRow, getKeyValueHeaders, - getKeyValueInitialCellError, - getKeyValueInitialRows, - getKeyValueTableKeysFrequency, + getKeyValueTableCellError, + getKeyValueTableRows, + getKeyValueTableSortedRows, getModifiedDataForOnChange, } from './utils' @@ -43,7 +33,7 @@ import './KeyValueTable.scss' export const KeyValueTable = ({ headerLabel, - initialRows, + rows: initialRows, placeholder, maskValue, isSortable, @@ -53,133 +43,50 @@ export const KeyValueTable = ({ readOnly, showError, validationSchema: parentValidationSchema, - errorMessages: parentErrorMessages = [], onError, validateDuplicateKeys = false, validateEmptyKeys = false, }: KeyValueTableProps) => { // STATES - const [rows, setRows] = useState( - getKeyValueInitialRows({ initialRows, placeholder }), - ) - - const [cellError, setCellError] = useState( - getKeyValueInitialCellError(rows), - ) + const [cellError, setCellError] = useState({}) // HOOKS const { sortBy, sortOrder, handleSorting } = useStateFilters({ initialSortKey: isSortable ? 'key' : null, }) - const rowWithMaskedValues = useMemo(() => { - if (maskValue && Object.keys(maskValue).length) { - return rows.map((row) => ({ - ...row, - data: { - ...row.data, - key: { - ...row.data.key, - value: maskValue.key ? DEFAULT_SECRET_PLACEHOLDER : row.data.key.value, - }, - value: { - ...row.data.value, - value: maskValue.value ? DEFAULT_SECRET_PLACEHOLDER : row.data.value.value, - }, - }, - })) - } - - return rows - }, [rows, maskValue]) - - const debounceOnChange = useCallback( - debounce((modifiedRows: KeyValueTableData[]) => - typeof onChange === 'function' ? onChange(modifiedRows) : noop, - ), - [], + // COMPUTED ROWS FOR DYNAMIC DATA TABLE + const rows = useMemo( + () => getKeyValueTableRows({ rows: initialRows, placeholder, maskValue }), + [initialRows, placeholder, maskValue, isSortable, sortOrder, sortBy], ) - // METHODS - const validationSchema = ( - value: Parameters[0], - key: Parameters[1], - rowId: Parameters[2], - keysFrequency: Record = {}, - ): DynamicDataTableCellValidationState => { - const trimmedValue = value.trim() - - if (validateDuplicateKeys && key === 'key' && (keysFrequency[trimmedValue] ?? 0) > 1) { - return { - isValid: false, - errorMessages: [DUPLICATE_KEYS_VALIDATION_MESSAGE], - } - } - - if (validateEmptyKeys && key === 'key' && !trimmedValue) { - const isValuePresentAtRow = rows.some(({ id, data }) => id === rowId && data.value.value.trim()) - if (isValuePresentAtRow) { - return { - isValid: false, - errorMessages: [EMPTY_KEY_VALIDATION_MESSAGE], - } - } - } - - if (parentValidationSchema) { - const isValid = parentValidationSchema(value, key, rowId) - return { - isValid, - errorMessages: !isValid ? parentErrorMessages : [], - } - } - - return { - isValid: true, - errorMessages: [], - } - } + // Set cell error on mount + useEffect(() => { + const { isValid, updatedCellError } = getKeyValueTableCellError({ + rows, + validateDuplicateKeys, + validateEmptyKeys, + validationSchema: parentValidationSchema, + }) - const checkAllRowsAreValid = (updatedRows: typeof rows) => { - let isValid = true - - const updatedCellError = updatedRows.reduce((acc, { data, id }) => { - const keyError = validationSchema( - data.key.value, - 'key', - id, - validateDuplicateKeys ? getKeyValueTableKeysFrequency(rows) : {}, - ) - const valueError = validationSchema(data.value.value, 'value', id) - - if (isValid && !(keyError.isValid && valueError.isValid)) { - isValid = false - } - - acc[id] = { - key: keyError, - value: valueError, - } - - return acc - }, {}) - - return { isValid, updatedCellError } - } + setCellError(updatedCellError) + onError?.(!isValid) + }, []) - const setUpdatedRows = (updatedRows: typeof rows, shouldDebounceChange = false) => { - const { isValid, updatedCellError } = checkAllRowsAreValid(updatedRows) + // METHODS + const setUpdatedRows = (updatedRows: typeof rows) => { + const { isValid, updatedCellError } = getKeyValueTableCellError({ + rows: updatedRows, + validateDuplicateKeys, + validateEmptyKeys, + validationSchema: parentValidationSchema, + }) - setRows(updatedRows) setCellError(updatedCellError) - onError?.(!isValid) - if (shouldDebounceChange) { - debounceOnChange(getModifiedDataForOnChange(updatedRows)) - } else { - onChange?.(getModifiedDataForOnChange(updatedRows)) - } + onChange(getModifiedDataForOnChange(updatedRows)) } const onRowAdd = () => { @@ -211,31 +118,13 @@ export const KeyValueTable = ({ updatedRows[rowIndex] = selectedRow } - setUpdatedRows(updatedRows, true) - } - - const onSorting = (_sortBy: KeyValueTableDataType) => { - handleSorting(_sortBy) - - if (isSortable) { - setRows((prevRows) => { - const sortedRows = prevRows - sortedRows.sort((a, b) => - stringComparatorBySortOrder( - a.data[_sortBy].value, - b.data[_sortBy].value, - sortOrder === SortingOrder.ASC ? SortingOrder.DESC : SortingOrder.ASC, - ), - ) - return sortedRows - }) - } + setUpdatedRows(updatedRows) } return ( ) diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts b/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts index 7b06308b5..fff70a1b0 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.types.ts @@ -92,9 +92,9 @@ export type KeyValueTableProps = Pick< */ headerLabel: KeyValueHeaderLabel /** - * The initial rows of the key-value table. + * The rows of the key-value table. */ - initialRows: KeyValueTableRowType[] + rows: KeyValueTableRowType[] /** * An optional configuration to mask values in the table. */ @@ -112,22 +112,29 @@ export type KeyValueTableProps = Pick< * * @param data - The updated table data. */ - onChange?: (data: KeyValueTableData[]) => void + onChange: (data: KeyValueTableData[]) => void /** * A function to validate the value of a cell. * * @param value - The value to validate. * @param key - The key of the header associated with the value. - * @param rowId - The id of the row containing the value. + * @param row - The row containing the value. * @returns A boolean indicating whether the value is valid. If false, * `showError` should be set to `true` and `errorMessages` should * provide an array of error messages to display. */ - validationSchema?: (value: string, key: KeyValueTableDataType, rowId: KeyValueTableRowType['id']) => boolean - /** - * An array of error messages to display in the cell error tooltip. - */ - errorMessages?: string[] + validationSchema?: ( + value: string, + key: KeyValueTableDataType, + row: KeyValueTableRowType, + ) => { + /** Boolean indicating if the cell data is valid or not. */ + isValid: boolean + /** + * An array of error messages to display in the cell error tooltip. + */ + errorMessages?: string[] + } /** * A callback function triggered when an error occurs in the table. * @@ -136,3 +143,11 @@ export type KeyValueTableProps = Pick< */ onError?: (errorState: boolean) => void } & ErrorUIProps + +export type KeyValueValidationSchemaProps = { + value: Parameters[0] + key: Parameters[1] + row: Parameters[2] + keysFrequency?: Record +} & Pick & + Partial> diff --git a/src/Shared/Components/KeyValueTable/utils.ts b/src/Shared/Components/KeyValueTable/utils.ts index fba83b5f0..e292aecbb 100644 --- a/src/Shared/Components/KeyValueTable/utils.ts +++ b/src/Shared/Components/KeyValueTable/utils.ts @@ -1,7 +1,16 @@ -import { getUniqueId } from '@Shared/Helpers' +import { UseStateFiltersReturnType } from '@Common/Hooks' +import { DEFAULT_SECRET_PLACEHOLDER } from '@Shared/constants' +import { getUniqueId, stringComparatorBySortOrder } from '@Shared/Helpers' -import { DynamicDataTableRowDataType } from '../DynamicDataTable' -import { KeyValueTableData, KeyValueTableInternalProps, KeyValueTableProps } from './KeyValueTable.types' +import { DynamicDataTableCellValidationState, DynamicDataTableRowDataType } from '../DynamicDataTable' +import { DUPLICATE_KEYS_VALIDATION_MESSAGE, EMPTY_KEY_VALIDATION_MESSAGE } from './constants' +import { + KeyValueTableData, + KeyValueTableDataType, + KeyValueTableInternalProps, + KeyValueTableProps, + KeyValueValidationSchemaProps, +} from './KeyValueTable.types' export const getModifiedDataForOnChange = (rows: KeyValueTableInternalProps['rows']): KeyValueTableData[] => rows.map(({ data, id }) => ({ id, key: data.key.value, value: data.value.value })) @@ -24,21 +33,26 @@ export const getEmptyRow = ( }, }) -export const getKeyValueInitialRows = ({ - initialRows, +export const getKeyValueTableRows = ({ + rows: initialRows, placeholder, -}: Pick): KeyValueTableInternalProps['rows'] => - initialRows.length + maskValue, +}: Required>): KeyValueTableInternalProps['rows'] => { + const isMaskValue = maskValue && Object.keys(maskValue).length + + const rows: KeyValueTableInternalProps['rows'] = initialRows.length ? initialRows.map(({ data: { key, value }, id }) => ({ data: { key: { ...key, type: DynamicDataTableRowDataType.TEXT, + value: isMaskValue && maskValue.key ? DEFAULT_SECRET_PLACEHOLDER : key.value, props: { placeholder: placeholder.key }, }, value: { ...value, type: DynamicDataTableRowDataType.TEXT, + value: isMaskValue && maskValue.value ? DEFAULT_SECRET_PLACEHOLDER : value.value, props: { placeholder: placeholder.value }, }, }, @@ -46,19 +60,25 @@ export const getKeyValueInitialRows = ({ })) : [getEmptyRow(placeholder)] -export const getKeyValueInitialCellError = ( - rows: KeyValueTableInternalProps['rows'], -): KeyValueTableInternalProps['cellError'] => - rows.reduce((acc, curr) => { - if (!acc[curr.id]) { - acc[curr.id] = { - key: { isValid: true, errorMessages: [] }, - value: { isValid: true, errorMessages: [] }, - } - } + return rows +} - return acc - }, {}) +export const getKeyValueTableSortedRows = ({ + isSortable, + rows, + sortBy, + sortOrder, +}: Required> & + Required, 'sortBy' | 'sortOrder'>> & + Pick) => { + if (isSortable) { + return rows + .map((item) => item) + .sort((a, b) => stringComparatorBySortOrder(a.data[sortBy].value, b.data[sortBy].value, sortOrder)) + } + + return rows +} export const getKeyValueHeaders = ({ headerLabel, @@ -68,7 +88,7 @@ export const getKeyValueHeaders = ({ { key: 'value', label: headerLabel.value, width: '1fr' }, ] -export const getKeyValueTableKeysFrequency = (rows: KeyValueTableInternalProps['rows']) => +const getKeyValueTableKeysFrequency = (rows: KeyValueTableInternalProps['rows']) => rows.reduce( (acc, curr) => { const currentKey = curr.data.key.value @@ -79,3 +99,90 @@ export const getKeyValueTableKeysFrequency = (rows: KeyValueTableInternalProps[' }, {} as Record, ) + +const validationSchema = ({ + value, + key, + row, + validateDuplicateKeys, + validateEmptyKeys, + validationSchema: parentValidationSchema, + rows = [], + keysFrequency = {}, +}: KeyValueValidationSchemaProps): DynamicDataTableCellValidationState => { + const trimmedValue = value.trim() + + if (validateDuplicateKeys && key === 'key' && (keysFrequency[trimmedValue] ?? 0) > 1) { + return { + isValid: false, + errorMessages: [DUPLICATE_KEYS_VALIDATION_MESSAGE], + } + } + + if (validateEmptyKeys && key === 'key' && !trimmedValue) { + const isValuePresentAtRow = rows.some(({ id, data }) => id === row.id && data.value.value.trim()) + if (isValuePresentAtRow) { + return { + isValid: false, + errorMessages: [EMPTY_KEY_VALIDATION_MESSAGE], + } + } + } + + if (parentValidationSchema) { + const { isValid, errorMessages } = parentValidationSchema(value, key, row) + return { + isValid, + errorMessages: errorMessages || [], + } + } + + return { + isValid: true, + errorMessages: [], + } +} + +export const getKeyValueTableCellError = ({ + validateDuplicateKeys, + validateEmptyKeys, + validationSchema: parentValidationSchema, + rows, +}: Pick & { + skipValidationIfValueIsEmpty?: boolean +}) => { + let isValid = true + + const updatedCellError = rows.reduce((acc, row) => { + const keyError = validationSchema({ + rows, + value: row.data.key.value, + key: 'key', + row, + validateDuplicateKeys, + validateEmptyKeys, + validationSchema: parentValidationSchema, + keysFrequency: validateDuplicateKeys ? getKeyValueTableKeysFrequency(rows) : {}, + }) + + const valueError = validationSchema({ + value: row.data.value.value, + key: 'value', + row, + validationSchema: parentValidationSchema, + }) + + if (isValid && !(keyError.isValid && valueError.isValid)) { + isValid = false + } + + acc[row.id] = { + key: keyError, + value: valueError, + } + + return acc + }, {}) + + return { isValid, updatedCellError } +} From 2007b0fb36b72ae03849c0dfd732e8546bc6e10a Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Mon, 21 Apr 2025 18:13:40 +0530 Subject: [PATCH 06/14] feat: add ConditionDetails interface and related error handling types --- src/Common/CIPipeline.Types.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Common/CIPipeline.Types.ts b/src/Common/CIPipeline.Types.ts index 8dbddd04a..04ebfd015 100644 --- a/src/Common/CIPipeline.Types.ts +++ b/src/Common/CIPipeline.Types.ts @@ -158,7 +158,7 @@ export interface PortMapType { portOnLocal: number portOnContainer: number } -interface ConditionDetails { +export interface ConditionDetails { id: number conditionOnVariable: string conditionOperator: string @@ -314,8 +314,16 @@ export enum InputOutputVariablesHeaderKeys { VALUE = 'val', } +export enum ConditionDataTableHeaderKeys { + VARIABLE = 'variable', + OPERATOR = 'operator', + VALUE = 'val', +} + export type InputOutputVariablesErrorObj = Record +export type ConditionDetailsErrorObj = Record + export interface TaskErrorObj { isValid: boolean name: ErrorObj @@ -324,12 +332,16 @@ export interface TaskErrorObj { outputVariables?: Record isInputVariablesValid?: boolean isOutputVariablesValid?: boolean + conditionDetails?: Record + isConditionDetailsValid?: boolean } pluginRefStepDetail?: { inputVariables?: Record outputVariables?: Record isInputVariablesValid?: boolean isOutputVariablesValid?: boolean + conditionDetails?: Record + isConditionDetailsValid?: boolean } } export interface FormErrorObjectType { From 638af5a34b4484f1db9368b1707d544999387900 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Mon, 21 Apr 2025 18:15:40 +0530 Subject: [PATCH 07/14] fix: update error handling in DynamicDataTableRow for SelectPicker and Dropdown --- .../Components/DynamicDataTable/DynamicDataTableRow.tsx | 8 +++++--- src/Shared/Components/KeyValueTable/utils.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Shared/Components/DynamicDataTable/DynamicDataTableRow.tsx b/src/Shared/Components/DynamicDataTable/DynamicDataTableRow.tsx index 3904c5147..40fb9a572 100644 --- a/src/Shared/Components/DynamicDataTable/DynamicDataTableRow.tsx +++ b/src/Shared/Components/DynamicDataTable/DynamicDataTableRow.tsx @@ -290,16 +290,18 @@ export const DynamicDataTableRow = {errorMessages.map((error) => renderErrorMessage(error))} diff --git a/src/Shared/Components/KeyValueTable/utils.ts b/src/Shared/Components/KeyValueTable/utils.ts index e292aecbb..aaa7dc7b6 100644 --- a/src/Shared/Components/KeyValueTable/utils.ts +++ b/src/Shared/Components/KeyValueTable/utils.ts @@ -40,7 +40,7 @@ export const getKeyValueTableRows = ({ }: Required>): KeyValueTableInternalProps['rows'] => { const isMaskValue = maskValue && Object.keys(maskValue).length - const rows: KeyValueTableInternalProps['rows'] = initialRows.length + const rows: KeyValueTableInternalProps['rows'] = initialRows?.length ? initialRows.map(({ data: { key, value }, id }) => ({ data: { key: { From 3376c4bb05e572231a7a3a8ec433f1e9de50b7e0 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Wed, 23 Apr 2025 22:56:04 +0530 Subject: [PATCH 08/14] refactor: types refactor --- src/Common/CIPipeline.Types.ts | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/Common/CIPipeline.Types.ts b/src/Common/CIPipeline.Types.ts index 04ebfd015..c0bcb8aa0 100644 --- a/src/Common/CIPipeline.Types.ts +++ b/src/Common/CIPipeline.Types.ts @@ -320,29 +320,23 @@ export enum ConditionDataTableHeaderKeys { VALUE = 'val', } -export type InputOutputVariablesErrorObj = Record +type InputOutputVariablesErrorObj = Record +type ConditionDetailsErrorObj = Record -export type ConditionDetailsErrorObj = Record +interface StepDetailTaskErrorObj { + inputVariables?: Record + outputVariables?: Record + isInputVariablesValid?: boolean + isOutputVariablesValid?: boolean + conditionDetails?: Record + isConditionDetailsValid?: boolean +} export interface TaskErrorObj { isValid: boolean name: ErrorObj - inlineStepDetail?: { - inputVariables?: Record - outputVariables?: Record - isInputVariablesValid?: boolean - isOutputVariablesValid?: boolean - conditionDetails?: Record - isConditionDetailsValid?: boolean - } - pluginRefStepDetail?: { - inputVariables?: Record - outputVariables?: Record - isInputVariablesValid?: boolean - isOutputVariablesValid?: boolean - conditionDetails?: Record - isConditionDetailsValid?: boolean - } + inlineStepDetail?: StepDetailTaskErrorObj + pluginRefStepDetail?: StepDetailTaskErrorObj } export interface FormErrorObjectType { name: ErrorObj From 756104bdbb6dd5207a18fa0a70b3a17f848fce33 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Thu, 24 Apr 2025 12:13:39 +0530 Subject: [PATCH 09/14] feat: add addBtnTooltip prop to DynamicDataTableHeader for customizable add button tooltip --- .../Components/DynamicDataTable/DynamicDataTableHeader.tsx | 4 ++-- src/Shared/Components/DynamicDataTable/types.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx b/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx index a6d59cb91..45bd7e2a6 100644 --- a/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx +++ b/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx @@ -27,6 +27,7 @@ export const DynamicDataTableHeader = } variant={ButtonVariantType.borderLess} size={ComponentSizeType.xs} - showAriaLabelInTippy={false} /> )} {key === lastHeaderKey && headerComponent} diff --git a/src/Shared/Components/DynamicDataTable/types.ts b/src/Shared/Components/DynamicDataTable/types.ts index 8d3785d17..f8e378eaf 100644 --- a/src/Shared/Components/DynamicDataTable/types.ts +++ b/src/Shared/Components/DynamicDataTable/types.ts @@ -169,6 +169,10 @@ export type DynamicDataTableProps void /** @@ -233,6 +237,7 @@ export interface DynamicDataTableHeaderProps Date: Fri, 25 Apr 2025 11:49:29 +0530 Subject: [PATCH 10/14] chore(version): bump to 1.11.0-beta-10 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 35ca05a26..3d84bc063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.11.0-pre-9", + "version": "1.11.0-beta-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.11.0-pre-9", + "version": "1.11.0-beta-10", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 52c8ee5d2..7c462272d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.11.0-pre-9", + "version": "1.11.0-beta-10", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", From 453427d4c5fc16398d3903558653e49c41b4085d Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Tue, 29 Apr 2025 01:24:22 +0530 Subject: [PATCH 11/14] feat: KeyValueTable - implement sorting functionality --- .../KeyValueTable/KeyValueTable.component.tsx | 64 ++++++++++++++++++- src/Shared/Components/KeyValueTable/utils.ts | 17 ++--- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx index 968e3cb42..f466372ec 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx @@ -16,10 +16,16 @@ import { useEffect, useMemo, useState } from 'react' +import { useEffectAfterMount } from '@Common/Helper' import { useStateFilters } from '@Common/Hooks' import { DynamicDataTable } from '../DynamicDataTable' -import { KeyValueTableDataType, KeyValueTableInternalProps, KeyValueTableProps } from './KeyValueTable.types' +import { + KeyValueTableDataType, + KeyValueTableInternalProps, + KeyValueTableProps, + KeyValueTableRowType, +} from './KeyValueTable.types' import { getEmptyRow, getKeyValueHeaders, @@ -49,6 +55,7 @@ export const KeyValueTable = ({ }: KeyValueTableProps) => { // STATES const [cellError, setCellError] = useState({}) + const [sortedRows, setSortedRows] = useState([]) // HOOKS const { sortBy, sortOrder, handleSorting } = useStateFilters({ @@ -61,8 +68,21 @@ export const KeyValueTable = ({ [initialRows, placeholder, maskValue, isSortable, sortOrder, sortBy], ) - // Set cell error on mount + /** Function to update the sorted rows based on the current sorting configuration */ + const updateSortedRows = () => { + if (isSortable) { + setSortedRows( + getKeyValueTableSortedRows({ + rows, + sortBy, + sortOrder, + }), + ) + } + } + useEffect(() => { + // Set cell error on mount const { isValid, updatedCellError } = getKeyValueTableCellError({ rows, validateDuplicateKeys, @@ -72,8 +92,46 @@ export const KeyValueTable = ({ setCellError(updatedCellError) onError?.(!isValid) + + // Set sorted rows on mount + updateSortedRows() }, []) + // Sort rows for display purposes only. \ + // The `sortedRows` state is used internally to render the data, while the original `rows` prop remains unaltered during sorting. + useEffectAfterMount(() => { + if (isSortable) { + // Create a map of rows using their IDs for quick lookup + const rowMap = rows.reduce>( + (acc, row) => { + acc[row.id] = row + return acc + }, + {}, + ) + + // Create a set of IDs from the currently sorted rows for efficient lookup + const sortedRowSet = new Set(sortedRows.map(({ id }) => id)) + + // Update the sorted rows by filtering out rows that no longer exist and mapping them to the latest data + const updatedSortedRows = sortedRows.filter(({ id }) => rowMap[id]).map(({ id }) => rowMap[id]) + + // Identify rows that are not part of the current sorted set (new or unsorted rows) + const unsortedRows = rows.filter(({ id }) => !sortedRowSet.has(id)) + + // Combine unsorted rows with updated sorted rows and set them as the new sorted rows + setSortedRows([...unsortedRows, ...updatedSortedRows]) + } else { + // If sorting is disabled, directly set the rows as the sorted rows + setSortedRows(rows) + } + }, [rows]) + + // Update the sorted rows whenever the sorting configuration changes + useEffectAfterMount(() => { + updateSortedRows() + }, [sortBy, sortOrder]) + // METHODS const setUpdatedRows = (updatedRows: typeof rows) => { const { isValid, updatedCellError } = getKeyValueTableCellError({ @@ -124,7 +182,7 @@ export const KeyValueTable = ({ return ( > & - Required, 'sortBy' | 'sortOrder'>> & - Pick) => { - if (isSortable) { - return rows - .map((item) => item) - .sort((a, b) => stringComparatorBySortOrder(a.data[sortBy].value, b.data[sortBy].value, sortOrder)) - } - - return rows -} +}: Required, 'sortBy' | 'sortOrder'>> & + Pick) => + rows + .map((item) => item) + .sort((a, b) => stringComparatorBySortOrder(a.data[sortBy].value, b.data[sortBy].value, sortOrder)) export const getKeyValueHeaders = ({ headerLabel, From 96d1b4debdc4ad238ce4eb93a0128e559786f735 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Tue, 29 Apr 2025 01:25:28 +0530 Subject: [PATCH 12/14] chore(version): bump to 1.12.0-beta-3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3d84bc063..cae0ef4b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.11.0-beta-10", + "version": "1.12.0-beta-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.11.0-beta-10", + "version": "1.12.0-beta-3", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 7c462272d..cc5f0dd5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.11.0-beta-10", + "version": "1.12.0-beta-3", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", From 0ccf6ea3c230e898a05c7e6919ddd3c6fd5d8562 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Tue, 29 Apr 2025 11:35:42 +0530 Subject: [PATCH 13/14] refactor: KeyValueTable - optimize sorting logic and improve performance --- .../KeyValueTable/KeyValueTable.component.tsx | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx index f466372ec..e43a693a9 100644 --- a/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx +++ b/src/Shared/Components/KeyValueTable/KeyValueTable.component.tsx @@ -20,12 +20,7 @@ import { useEffectAfterMount } from '@Common/Helper' import { useStateFilters } from '@Common/Hooks' import { DynamicDataTable } from '../DynamicDataTable' -import { - KeyValueTableDataType, - KeyValueTableInternalProps, - KeyValueTableProps, - KeyValueTableRowType, -} from './KeyValueTable.types' +import { KeyValueTableDataType, KeyValueTableInternalProps, KeyValueTableProps } from './KeyValueTable.types' import { getEmptyRow, getKeyValueHeaders, @@ -100,31 +95,26 @@ export const KeyValueTable = ({ // Sort rows for display purposes only. \ // The `sortedRows` state is used internally to render the data, while the original `rows` prop remains unaltered during sorting. useEffectAfterMount(() => { - if (isSortable) { - // Create a map of rows using their IDs for quick lookup - const rowMap = rows.reduce>( - (acc, row) => { - acc[row.id] = row - return acc - }, - {}, - ) + if (!isSortable) { + // If sorting is disabled, directly set rows without any processing + setSortedRows(rows) + return + } - // Create a set of IDs from the currently sorted rows for efficient lookup - const sortedRowSet = new Set(sortedRows.map(({ id }) => id)) + // Create a mapping of row IDs to row objects for quick lookup + const rowMap = new Map(rows.map((row) => [row.id, row])) - // Update the sorted rows by filtering out rows that no longer exist and mapping them to the latest data - const updatedSortedRows = sortedRows.filter(({ id }) => rowMap[id]).map(({ id }) => rowMap[id]) + // Create a set of IDs from the current sorted rows for efficient membership checking + const sortedRowIds = new Set(sortedRows.map((row) => row.id)) - // Identify rows that are not part of the current sorted set (new or unsorted rows) - const unsortedRows = rows.filter(({ id }) => !sortedRowSet.has(id)) + // Update the sorted rows by mapping them to the latest version from `rows` and filtering out any rows that no longer exist + const updatedSortedRows = sortedRows.map((row) => rowMap.get(row.id)).filter(Boolean) - // Combine unsorted rows with updated sorted rows and set them as the new sorted rows - setSortedRows([...unsortedRows, ...updatedSortedRows]) - } else { - // If sorting is disabled, directly set the rows as the sorted rows - setSortedRows(rows) - } + // Find any new rows that are not already in the sorted list + const newUnsortedRows = rows.filter((row) => !sortedRowIds.has(row.id)) + + // Combine new unsorted rows (at the top) with the updated sorted rows (preserving original order) + setSortedRows([...newUnsortedRows, ...updatedSortedRows]) }, [rows]) // Update the sorted rows whenever the sorting configuration changes From 39a264ec15228714a8f7073ee4375fe8d79f7abc Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Tue, 29 Apr 2025 12:11:48 +0530 Subject: [PATCH 14/14] chore(version): bump to 1.12.0-pre-1 --- package-lock.json | 4 ++-- package.json | 2 +- .../Components/DynamicDataTable/DynamicDataTableHeader.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40ebd223c..f48dbe2db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.12.0-pre-0", + "version": "1.12.0-pre-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.12.0-pre-0", + "version": "1.12.0-pre-1", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 6fe84e4cf..e28f6c0be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.12.0-pre-0", + "version": "1.12.0-pre-1", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", diff --git a/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx b/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx index 45bd7e2a6..e0f6eb83a 100644 --- a/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx +++ b/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx @@ -27,7 +27,7 @@ export const DynamicDataTableHeader =