diff --git a/frontend/src/concepts/connectionTypes/types.ts b/frontend/src/concepts/connectionTypes/types.ts index edf813b622..7ffecda491 100644 --- a/frontend/src/concepts/connectionTypes/types.ts +++ b/frontend/src/concepts/connectionTypes/types.ts @@ -101,6 +101,10 @@ export type ConnectionTypeField = export type ConnectionTypeDataField = Exclude; +export const isConnectionTypeDataField = ( + field: ConnectionTypeField, +): field is ConnectionTypeDataField => field.type !== ConnectionTypeFieldType.Section; + export type ConnectionTypeConfigMap = K8sResourceCommon & { metadata: { name: string; diff --git a/frontend/src/pages/connectionTypes/manage/ConnectionTypeDataFieldModal.tsx b/frontend/src/pages/connectionTypes/manage/ConnectionTypeDataFieldModal.tsx index da673ea075..3c9b27d2bc 100644 --- a/frontend/src/pages/connectionTypes/manage/ConnectionTypeDataFieldModal.tsx +++ b/frontend/src/pages/connectionTypes/manage/ConnectionTypeDataFieldModal.tsx @@ -14,14 +14,19 @@ import { SelectOption, TextArea, TextInput, - ValidatedOptions, } from '@patternfly/react-core'; -import { ExclamationCircleIcon, OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import { + ExclamationCircleIcon, + OutlinedQuestionCircleIcon, + WarningTriangleIcon, +} from '@patternfly/react-icons'; import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; import { ConnectionTypeDataField, connectionTypeDataFields, + ConnectionTypeField, ConnectionTypeFieldType, + isConnectionTypeDataField, } from '~/concepts/connectionTypes/types'; import { fieldNameToEnvVar, fieldTypeToString } from '~/concepts/connectionTypes/utils'; import { isEnumMember } from '~/utilities/utils'; @@ -41,6 +46,7 @@ type Props = { onClose: () => void; onSubmit: (field: ConnectionTypeDataField) => void; isEdit?: boolean; + fields?: ConnectionTypeField[]; }; export const ConnectionTypeDataFieldModal: React.FC = ({ @@ -49,6 +55,7 @@ export const ConnectionTypeDataFieldModal: React.FC = ({ onClose, onSubmit, isEdit, + fields, }) => { const [name, setName] = React.useState(field?.name || ''); const [description, setDescription] = React.useState(field?.description); @@ -64,16 +71,16 @@ export const ConnectionTypeDataFieldModal: React.FC = ({ const [isTypeSelectOpen, setIsTypeSelectOpen] = React.useState(false); const [properties, setProperties] = React.useState(field?.properties || {}); const [isPropertiesValid, setPropertiesValid] = React.useState(true); - const [autoGenerateEnvVar, setAutoGenerateEnvVar] = React.useState(!envVar); - const envVarValidation = - !envVar || ENV_VAR_NAME_REGEX.test(envVar) ? ValidatedOptions.default : ValidatedOptions.error; - const isValid = - !!fieldType && - isPropertiesValid && - !!name && - !!envVar && - envVarValidation === ValidatedOptions.default; + + const isEnvVarConflict = React.useMemo( + () => !!fields?.find((f) => f !== field && isConnectionTypeDataField(f) && f.envVar === envVar), + [fields, field, envVar], + ); + + const isEnvVarValid = !envVar || ENV_VAR_NAME_REGEX.test(envVar); + + const isValid = !!fieldType && isPropertiesValid && !!name && !!envVar && isEnvVarValid; const newField = fieldType ? // Cast from specific type to generic type @@ -178,17 +185,24 @@ export const ConnectionTypeDataFieldModal: React.FC = ({ setEnvVar(value); }} data-testid="field-env-var-input" - validated={envVarValidation} + validated={!isEnvVarValid ? 'error' : isEnvVarConflict ? 'warning' : 'default'} /> - {envVarValidation === ValidatedOptions.error ? ( - + + {!isEnvVarValid ? ( } variant="error"> {`Invalid variable name. The name must consist of alphabetic characters, digits, '_', '-', or '.', and must not start with a digit.`} - - ) : null} + ) : undefined} + {isEnvVarConflict ? ( + + } variant="warning"> + This environment variable name is already being used for an existing field. + + + ) : undefined} + = ({ selected={fieldType} onSelect={(_e, selection) => { if (isConnectionTypeFieldType(selection)) { + setPropertiesValid(true); setProperties({}); setFieldType(selection); setIsTypeSelectOpen(false); diff --git a/frontend/src/pages/connectionTypes/manage/ConnectionTypeFieldModal.tsx b/frontend/src/pages/connectionTypes/manage/ConnectionTypeFieldModal.tsx index afdfd629cd..f258deba28 100644 --- a/frontend/src/pages/connectionTypes/manage/ConnectionTypeFieldModal.tsx +++ b/frontend/src/pages/connectionTypes/manage/ConnectionTypeFieldModal.tsx @@ -9,6 +9,7 @@ type Props = { onClose: () => void; onSubmit: (field: ConnectionTypeField) => void; isEdit?: boolean; + fields?: ConnectionTypeField[]; }; const ConnectionTypeFieldModal: React.FC = (props) => { diff --git a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx index 1e7c8859b7..a12dc9e83e 100644 --- a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx +++ b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTable.tsx @@ -13,9 +13,10 @@ import { EmptyStateVariant, } from '@patternfly/react-core'; import { PlusCircleIcon } from '@patternfly/react-icons'; -import { Table, Thead, Tbody, Tr, Th, ThProps } from '@patternfly/react-table'; +import { Table, Thead, Tbody, Tr, Th } from '@patternfly/react-table'; import { ConnectionTypeField, ConnectionTypeFieldType } from '~/concepts/connectionTypes/types'; import useDraggableTableControlled from '~/utilities/useDraggableTableControlled'; +import { columns } from '~/pages/connectionTypes/manage/fieldTableColumns'; import ConnectionTypeFieldModal from './ConnectionTypeFieldModal'; import ManageConnectionTypeFieldsTableRow from './ManageConnectionTypeFieldsTableRow'; import { ConnectionTypeMoveFieldToSectionModal } from './ConnectionTypeFieldMoveModal'; @@ -52,14 +53,6 @@ type Props = { onFieldsChange: (fields: ConnectionTypeField[]) => void; }; -const columns: ThProps[] = [ - { label: 'Section heading/field name', width: 30 }, - { label: 'Type', width: 10 }, - { label: 'Default value', width: 30 }, - { label: 'Environment variable', width: 20 }, - { label: 'Required', width: 10 }, -]; - const ManageConnectionTypeFieldsTable: React.FC = ({ fields, onFieldsChange }) => { const [modalField, setModalField] = React.useState< { field?: ConnectionTypeField; index?: number; isEdit?: boolean } | undefined @@ -96,7 +89,6 @@ const ManageConnectionTypeFieldsTable: React.FC = ({ fields, onFieldsChan key={index} row={row} rowIndex={index} - columns={columns} fields={fields} onEdit={() => { setModalField({ @@ -164,6 +156,7 @@ const ManageConnectionTypeFieldsTable: React.FC = ({ fields, onFieldsChan )} {modalField ? ( setModalField(undefined)} diff --git a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx index d2ec63dac9..0294a9d332 100644 --- a/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx +++ b/frontend/src/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow.tsx @@ -1,19 +1,21 @@ import * as React from 'react'; -import { ActionsColumn, Td, ThProps, Tr } from '@patternfly/react-table'; -import { Button, Label, Switch } from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { Button, Icon, Label, Switch } from '@patternfly/react-core'; import { ConnectionTypeField, ConnectionTypeFieldType, SectionField, + isConnectionTypeDataField, } from '~/concepts/connectionTypes/types'; import { defaultValueToString, fieldTypeToString } from '~/concepts/connectionTypes/utils'; import type { RowProps } from '~/utilities/useDraggableTableControlled'; import TruncatedText from '~/components/TruncatedText'; +import { columns } from '~/pages/connectionTypes/manage/fieldTableColumns'; type Props = { row: ConnectionTypeField; rowIndex: number; - columns: ThProps[]; fields: ConnectionTypeField[]; onEdit: () => void; onRemove: () => void; @@ -26,7 +28,6 @@ type Props = { const ManageConnectionTypeFieldsTableRow: React.FC = ({ row, rowIndex, - columns, fields, onEdit, onRemove, @@ -45,6 +46,16 @@ const ManageConnectionTypeFieldsTableRow: React.FC = ({ return potentialSectionsToMoveTo > 0; }, [fields, rowIndex]); + const isEnvVarConflict = React.useMemo( + () => + row.type === ConnectionTypeFieldType.Section + ? false + : !!fields.find( + (f) => f !== row && isConnectionTypeDataField(f) && f.envVar === row.envVar, + ), + [row, fields], + ); + if (row.type === ConnectionTypeFieldType.Section) { return ( @@ -113,6 +124,14 @@ const ManageConnectionTypeFieldsTableRow: React.FC = ({ {row.envVar || '-'} + {isEnvVarConflict ? ( + <> + + + + This environment variable is in conflict. + + ) : undefined} = ({ prefill, isEdit, onSave }) [connectionNameDesc, connectionEnabled, connectionFields, username, category], ); + const isEnvVarConflict = React.useMemo(() => { + const envVars = connectionFields.filter(isConnectionTypeDataField).map((f) => f.envVar); + return uniq(envVars).length !== envVars.length; + }, [connectionFields]); + const isValid = React.useMemo(() => { const trimmedName = connectionNameDesc.name.trim(); - return Boolean(trimmedName); - }, [connectionNameDesc.name]); + return Boolean(trimmedName) && !isEnvVarConflict; + }, [connectionNameDesc.name, isEnvVarConflict]); const onCancel = () => { navigate('/connectionTypes'); @@ -144,6 +154,12 @@ const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave }) Add fields to prompt users to input information, and optionally assign default values to those fields. + {isEnvVarConflict ? ( + + Environment variables for one or more fields are conflicting. Change them to + resolve and proceed. + + ) : null} { + let onClose: jest.Mock; + let onSubmit: jest.Mock; + beforeEach(() => { onClose = jest.fn(); onSubmit = jest.fn(); @@ -230,4 +231,36 @@ describe('ConnectionTypeDataFieldModal', () => { expect(screen.getByTestId('modal-submit-button')).toBeDisabled(); }); + + it('should display env var conflict warning', () => { + const field: ShortTextField = { + type: 'short-text', + name: 'test', + envVar: 'test-envvar', + properties: {}, + }; + const field2: TextField = { + type: 'text', + name: 'test-2', + envVar: 'test-envvar', + properties: {}, + }; + render( + , + ); + const fieldEnvVarInput = screen.getByTestId('field-env-var-input'); + screen.getByTestId('envvar-conflict-warning'); + expect(screen.getByTestId('modal-submit-button')).not.toBeDisabled(); + + act(() => { + fireEvent.change(fieldEnvVarInput, { target: { value: 'new-env-value' } }); + }); + + expect(screen.queryByTestId('envvar-conflict-warning')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/connectionTypes/manage/__tests__/ManageConnectionTypeFieldsTableRow.spec.tsx b/frontend/src/pages/connectionTypes/manage/__tests__/ManageConnectionTypeFieldsTableRow.spec.tsx new file mode 100644 index 0000000000..e266970a21 --- /dev/null +++ b/frontend/src/pages/connectionTypes/manage/__tests__/ManageConnectionTypeFieldsTableRow.spec.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import ManageConnectionTypeFieldsTableRow from '~/pages/connectionTypes/manage/ManageConnectionTypeFieldsTableRow'; +import { ShortTextField, TextField } from '~/concepts/connectionTypes/types'; + +const renderRow = ( + props: Pick, 'row'> & + Partial>, +) => { + const fn = jest.fn(); + return ( + + + + +
+ ); +}; + +describe('ManageConnectionTypeFieldsTableRow', () => { + it('should display env variable conflict icon', () => { + const field: ShortTextField = { + type: 'short-text', + name: 'test', + envVar: 'test-envvar', + properties: {}, + }; + const field2: TextField = { + type: 'text', + name: 'test-2', + envVar: 'test-envvar', + properties: {}, + }; + + const result = render(renderRow({ row: field, fields: [field] })); + expect(screen.getByTestId('field-env')).not.toHaveTextContent('conflict'); + + result.rerender(renderRow({ row: field, fields: [field, field2] })); + expect(screen.getByTestId('field-env')).toHaveTextContent('conflict'); + }); +}); diff --git a/frontend/src/pages/connectionTypes/manage/fieldTableColumns.ts b/frontend/src/pages/connectionTypes/manage/fieldTableColumns.ts new file mode 100644 index 0000000000..baa7264dc3 --- /dev/null +++ b/frontend/src/pages/connectionTypes/manage/fieldTableColumns.ts @@ -0,0 +1,9 @@ +import { ThProps } from '@patternfly/react-table'; + +export const columns: ThProps[] = [ + { label: 'Section heading/field name', width: 30 }, + { label: 'Type', width: 10 }, + { label: 'Default value', width: 30 }, + { label: 'Environment variable', width: 20 }, + { label: 'Required', width: 10 }, +];