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/Common/CIPipeline.Types.ts b/src/Common/CIPipeline.Types.ts index 45ab04918..c0bcb8aa0 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 @@ -158,7 +158,7 @@ export interface PortMapType { portOnLocal: number portOnContainer: number } -interface ConditionDetails { +export interface ConditionDetails { id: number conditionOnVariable: string conditionOperator: 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[] @@ -314,23 +314,29 @@ export enum InputOutputVariablesHeaderKeys { VALUE = 'val', } -export type InputOutputVariablesErrorObj = Record +export enum ConditionDataTableHeaderKeys { + VARIABLE = 'variable', + OPERATOR = 'operator', + VALUE = 'val', +} + +type InputOutputVariablesErrorObj = Record +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 - } - pluginRefStepDetail?: { - inputVariables?: Record - outputVariables?: Record - isInputVariablesValid?: boolean - isOutputVariablesValid?: boolean - } + inlineStepDetail?: StepDetailTaskErrorObj + pluginRefStepDetail?: StepDetailTaskErrorObj } export interface FormErrorObjectType { name: ErrorObj 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/Components/DynamicDataTable/DynamicDataTableHeader.tsx b/src/Shared/Components/DynamicDataTable/DynamicDataTableHeader.tsx index a6d59cb91..e0f6eb83a 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/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/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 ( - - {children} - -) - -export const KeyValueTable = ({ - config, +export const KeyValueTable = ({ + headerLabel, + rows: initialRows, + placeholder, maskValue, isSortable, headerComponent, onChange, - onDelete, - placeholder, isAdditionNotAllowed, readOnly, showError, validationSchema: parentValidationSchema, - errorMessages: parentErrorMessages = [], 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 - - /** 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({}) + const [sortedRows, setSortedRows] = useState([]) // 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() }), {}) - } - - if (!valueTextAreaRef.current) { - valueTextAreaRef.current = updatedRows.reduce((acc, curr) => ({ ...acc, [curr.id]: createRef() }), {}) - } - - 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], + // COMPUTED ROWS FOR DYNAMIC DATA TABLE + const rows = useMemo( + () => getKeyValueTableRows({ rows: initialRows, placeholder, maskValue }), + [initialRows, placeholder, maskValue, isSortable, sortOrder, sortBy], ) - 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 - } - - if (validateEmptyKeys && key === firstHeaderKey && !trimmedValue) { - const isValuePresentAtRow = updatedRows.some( - ({ id, data }) => id === rowId && data[secondHeaderKey].value.trim(), - ) - if (isValuePresentAtRow) { - return false - } - } - } - - if (parentValidationSchema) { - return parentValidationSchema(value, key, rowId) - } - - return true - } - - 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 }, + /** Function to update the sorted rows based on the current sorting configuration */ + const updateSortedRows = () => { + if (isSortable) { + setSortedRows( + getKeyValueTableSortedRows({ + rows, + sortBy, + sortOrder, + }), ) - - if (isAnyKeyDuplicated) { - return false - } } + } - if (validateEmptyKeys) { - const isEmptyKeyPresent = editedRows.some( - (row) => !row.data[firstHeaderKey].value.trim() && row.data[secondHeaderKey].value.trim(), - ) + useEffect(() => { + // Set cell error on mount + const { isValid, updatedCellError } = getKeyValueTableCellError({ + rows, + validateDuplicateKeys, + validateEmptyKeys, + validationSchema: parentValidationSchema, + }) + + setCellError(updatedCellError) + onError?.(!isValid) + + // Set sorted rows on mount + updateSortedRows() + }, []) - if (isEmptyKeyPresent) { - return false - } + // 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) { + // If sorting is disabled, directly set rows without any processing + setSortedRows(rows) + return } - // 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), - ) + // Create a mapping of row IDs to row objects for quick lookup + const rowMap = new Map(rows.map((row) => [row.id, row])) - return isValid - } - - const getEmptyRow = (): KeyValueRow => { - const id = (Date.now() * Math.random()).toString(16) - const data = { - data: { - [firstHeaderKey]: { - value: '', - }, - [secondHeaderKey]: { - value: '', - }, - }, - id, - } as KeyValueRow + // Create a set of IDs from the current sorted rows for efficient membership checking + const sortedRowIds = new Set(sortedRows.map((row) => row.id)) - return data - } + // 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) - const handleAddNewRow = () => { - const data = getEmptyRow() - const editedRows = [data, ...updatedRows] + // Find any new rows that are not already in the sorted list + const newUnsortedRows = rows.filter((row) => !sortedRowIds.has(row.id)) - const { id } = data + // Combine new unsorted rows (at the top) with the updated sorted rows (preserving original order) + setSortedRows([...newUnsortedRows, ...updatedSortedRows]) + }, [rows]) - onError?.(!checkAllRowsAreValid(editedRows)) - setNewRowAdded(true) - setUpdatedRows(editedRows) + // Update the sorted rows whenever the sorting configuration changes + useEffectAfterMount(() => { + updateSortedRows() + }, [sortBy, sortOrder]) - keyTextAreaRef.current = { - ...(keyTextAreaRef.current || {}), - [id as string]: createRef(), - } - valueTextAreaRef.current = { - ...(valueTextAreaRef.current || {}), - [id as string]: createRef(), - } + // METHODS + const setUpdatedRows = (updatedRows: typeof rows) => { + const { isValid, updatedCellError } = getKeyValueTableCellError({ + rows: updatedRows, + validateDuplicateKeys, + validateEmptyKeys, + validationSchema: parentValidationSchema, + }) + + setCellError(updatedCellError) + onError?.(!isValid) + + 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 + const onRowAdd = () => { + const newRow = getEmptyRow(placeholder) + const updatedRows = [newRow, ...rows] - 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]) - - // 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) } 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..fff70a1b0 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 -} +export type KeyValueTableDataType = 'key' | 'value' -/** - * 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 -} - -/** - * 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,106 @@ 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' +> & { /** - * 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. + * The label for the table header. */ - onChange?: (rowId: string | number, headerKey: K, value: string) => void + headerLabel: KeyValueHeaderLabel /** - * An optional function to handle row deletions. - * @param deletedRowIndex - The index of the row that was deleted. + * The rows of the key-value table. */ - onDelete?: (deletedRowId: string | number) => void + rows: KeyValueTableRowType[] /** - * The function to use to validate the value of the 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. + * An optional configuration to mask values in the table. */ - validationSchema?: (value: string, key: K, rowId: string | number) => boolean + maskValue?: KeyValueMask /** - * An array of error messages to be displayed in the cell error tooltip. + * An optional placeholder configuration for the table columns. */ - errorMessages?: string[] + placeholder?: KeyValuePlaceholder /** - * A callback function called when an error occurs. - * @param errorState - The error state, true when any cell has error, otherwise false. + * An optional boolean indicating if the `key` column is sortable. + */ + isSortable?: boolean + /** + * A callback function triggered when the table rows change. + * + * @param data - The updated table data. + */ + 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 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, + 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. + * + * @param errorState - A boolean indicating the error state. True if any + * cell has an error, otherwise false. */ 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/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..ff6308872 --- /dev/null +++ b/src/Shared/Components/KeyValueTable/utils.ts @@ -0,0 +1,181 @@ +import { UseStateFiltersReturnType } from '@Common/Hooks' +import { DEFAULT_SECRET_PLACEHOLDER } from '@Shared/constants' +import { getUniqueId, stringComparatorBySortOrder } from '@Shared/Helpers' + +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 })) + +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 getKeyValueTableRows = ({ + rows: initialRows, + placeholder, + 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 }, + }, + }, + id, + })) + : [getEmptyRow(placeholder)] + + return rows +} + +export const getKeyValueTableSortedRows = ({ + rows, + sortBy, + sortOrder, +}: 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, + isSortable, +}: Pick): KeyValueTableInternalProps['headers'] => [ + { key: 'key', label: headerLabel.key, width: '30%', isSortable }, + { key: 'value', label: headerLabel.value, width: '1fr' }, +] + +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, + ) + +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 } +} 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