From 476840428e5554b957c180a95ab12224e663325b Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 14:23:20 -0300 Subject: [PATCH 01/16] feat(forms): migrate Input to TypeScript Convert Input.js to a typed function component (Input.tsx). React 19 ref-as-prop + useImperativeHandle for the E2E-guarded focus(); drop the unused react-maskedinput dependency and the checkbox/radio delegation. Unify the password/search icon widths and use the colorIconDanger token. Co-Authored-By: Claude Opus 4.8 --- frontend/web/components/base/forms/Input.js | 237 ------------------- frontend/web/components/base/forms/Input.tsx | 176 ++++++++++++++ 2 files changed, 176 insertions(+), 237 deletions(-) delete mode 100644 frontend/web/components/base/forms/Input.js create mode 100644 frontend/web/components/base/forms/Input.tsx diff --git a/frontend/web/components/base/forms/Input.js b/frontend/web/components/base/forms/Input.js deleted file mode 100644 index f00c39f82520..000000000000 --- a/frontend/web/components/base/forms/Input.js +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Created by kylejohnson on 30/07/2016. - */ -import MaskedInput from 'react-maskedinput' -import cn from 'classnames' -import Icon from 'components/icons/Icon' -import Radio from './Radio' -import Checkbox from './Checkbox' - -const maskedCharacters = { - 'a': { - validate(char) { - return /[ap]/.test(char) - }, - }, - 'm': { - transform() { - return 'm' - }, - validate(char) { - return /\w/.test(char) - }, - }, -} - -const sizeClassNames = { - default: '', - large: 'input-lg', - small: 'input-sm', - xSmall: 'input-xsm', -} - -const Input = class extends React.Component { - static displayName = 'Input' - - constructor(props, context) { - super(props, context) - this.state = { - shouldValidate: !!this.props.value || this.props.autoValidate, - type: this.props.type, - } - } - onFocus = (e) => { - this.setState({ - isFocused: true, - }) - this.props.onFocus && this.props.onFocus(e) - } - - focus = () => { - if (E2E) return - this.input.focus() - } - - onKeyDown = (e) => { - if (Utils.keys.isEscape(e)) { - this.input.blur() - } - this.props.onKeyDown && this.props.onKeyDown(e) - } - - validate = () => { - this.setState({ - shouldValidate: true, - }) - } - - onBlur = (e) => { - this.setState({ - isFocused: false, - shouldValidate: true, - }) - this.props.onBlur && this.props.onBlur(e) - } - - render() { - const { - centered, - disabled, - inputClassName, - isValid, - mask, - placeholderChar, - showSuccess, - size, - underline, - ...rest - } = this.props - - const invalid = this.state.shouldValidate && !isValid - const success = isValid && showSuccess - const className = cn( - { - 'focused': this.state.isFocused, - 'input-container': true, - 'input-underline': underline, - invalid, - 'password': this.props.type === 'password', - 'search': this.props.search, - success, - }, - this.props.className, - ) - - const innerClassName = cn( - { - 'input': true, - 'text-center': centered, - }, - inputClassName, - sizeClassNames[size], - ) - - if (this.props.type === 'checkbox') { - return ( - - ) - } else if (this.props.type === 'radio') { - return ( - - ) - } - - return ( -
- {mask ? ( - (this.input = c)} - {...rest} - mask={this.props.mask} - type={this.state.type} - formatCharacters={maskedCharacters} - onKeyDown={this.onKeyDown} - onFocus={this.onFocus} - onBlur={this.onBlur} - className={innerClassName} - placeholderChar={placeholderChar} - /> - ) : ( - (this.input = c)} - {...rest} - onFocus={this.onFocus} - onKeyDown={this.onKeyDown} - type={this.state.type} - onBlur={this.onBlur} - value={this.props.value} - className={innerClassName} - disabled={disabled} - autoComplete={ - this.props.enableAutoComplete ?? this.props.autocomplete - } - /> - )} - {this.props.type === 'password' && ( - { - if (!disabled) { - this.setState({ - type: this.state.type === 'password' ? 'text' : 'password', - }) - } - }} - > - - - )} - {this.props.search && ( - - - - )} -
- ) - } -} - -Input.defaultProps = { - className: '', - isValid: true, - placeholderChar: ' ', -} - -Input.propTypes = { - autocomplete: propTypes.string, - centered: propTypes.bool, - className: propTypes.any, - inputClassName: OptionalString, - isValid: propTypes.any, - mask: OptionalString, - onBlur: OptionalFunc, - onFocus: OptionalFunc, - onKeyDown: OptionalFunc, - onSearchChange: OptionalFunc, - placeholderChar: OptionalString, - search: propTypes.Boolean, - size: OptionalString, - underline: propTypes.bool, -} - -export default Input diff --git a/frontend/web/components/base/forms/Input.tsx b/frontend/web/components/base/forms/Input.tsx new file mode 100644 index 000000000000..5b63ec342081 --- /dev/null +++ b/frontend/web/components/base/forms/Input.tsx @@ -0,0 +1,176 @@ +import React, { + FocusEvent, + KeyboardEvent, + Ref, + useImperativeHandle, + useRef, + useState, +} from 'react' +import cn from 'classnames' +import Icon from 'components/icons/Icon' +import Utils from 'common/utils/utils' +import { colorIconDanger } from 'common/theme/tokens' + +type InputSize = 'default' | 'large' | 'small' | 'xSmall' + +// Imperative API exposed via ref (React 19 ref-as-prop, no forwardRef). +// focus() is a no-op under E2E, matching the original behaviour. +export interface InputMethods { + focus: () => void +} + +export interface InputProps + extends Omit, 'size'> { + autoValidate?: boolean + // Custom lowercase alias for the standard `autoComplete`, kept for back-compat. + autocomplete?: string + centered?: boolean + enableAutoComplete?: string + inputClassName?: string + isValid?: boolean + ref?: Ref + search?: boolean + showSuccess?: boolean + size?: InputSize + underline?: boolean +} + +const sizeClassNames: Record = { + default: '', + large: 'input-lg', + small: 'input-sm', + xSmall: 'input-xsm', +} + +// Width of the password reveal / search icons per input size. +const iconWidthBySize: Record = { + default: undefined, + large: 24, + small: 20, + xSmall: 18, +} + +const Input: React.FC = ({ + autoValidate, + autocomplete, + centered, + className = '', + disabled, + enableAutoComplete, + inputClassName, + isValid = true, + onBlur: onBlurProp, + onChange, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + ref, + search, + showSuccess, + size, + type: typeProp, + underline, + value, + ...rest +}) => { + const inputRef = useRef(null) + const [isFocused, setIsFocused] = useState(false) + const [shouldValidate, setShouldValidate] = useState( + !!value || !!autoValidate, + ) + const [type, setType] = useState(typeProp) + + // No-op under E2E to avoid programmatic focus stealing during tests; native + // autoFocus is unaffected. Matches the original Input.focus() behaviour. + useImperativeHandle(ref, () => ({ + focus: () => { + if (E2E) return + inputRef.current?.focus() + }, + })) + + const onFocus = (e: FocusEvent) => { + setIsFocused(true) + onFocusProp?.(e) + } + + const onBlur = (e: FocusEvent) => { + setIsFocused(false) + setShouldValidate(true) + onBlurProp?.(e) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (Utils.keys.isEscape(e)) { + e.currentTarget.blur() + } + onKeyDownProp?.(e) + } + + const invalid = shouldValidate && !isValid + const success = isValid && showSuccess + const sizeClassName = size ? sizeClassNames[size] : '' + const containerClassName = cn( + { + 'focused': isFocused, + 'input-container': true, + 'input-underline': underline, + invalid, + 'password': typeProp === 'password', + 'search': search, + success, + }, + className, + ) + const innerClassName = cn( + { 'input': true, 'text-center': centered }, + inputClassName, + sizeClassName, + ) + const iconWidth = size ? iconWidthBySize[size] : undefined + + return ( +
+ + {typeProp === 'password' && ( + { + if (!disabled) { + setType(type === 'password' ? 'text' : 'password') + } + }} + > + + + )} + {search && ( + + + + )} +
+ ) +} + +Input.displayName = 'Input' + +export default Input From 9506e609735696b8b40d1d0ac044dcb8cfeaced2 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 14:23:22 -0300 Subject: [PATCH 02/16] refactor(integrations): render Checkbox directly Input no longer delegates type='checkbox' to Checkbox, so the single caller uses directly. Co-Authored-By: Claude Opus 4.8 --- .../web/components/modals/CreateEditIntegrationModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/modals/CreateEditIntegrationModal.tsx b/frontend/web/components/modals/CreateEditIntegrationModal.tsx index feec008ca93a..538a521f2990 100644 --- a/frontend/web/components/modals/CreateEditIntegrationModal.tsx +++ b/frontend/web/components/modals/CreateEditIntegrationModal.tsx @@ -11,6 +11,7 @@ import Project from 'common/project' import AccountStore from 'common/stores/account-store' import Utils from 'common/utils/utils' import Input from 'components/base/forms/Input' +import Checkbox from 'components/base/forms/Checkbox' import { IntegrationData, IntegrationField, @@ -397,12 +398,11 @@ const CreateEditIntegration: FC = (props) => { if (field.inputType === 'checkbox') { return (
- update(field.key, e)} - type='checkbox' + checked={!!(formData[field.key] ?? field.default)} + onChange={(value) => update(field.key, value)} />
) From 8098a184c06c5156ec22fec65f907fb784e6ccc9 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 14:23:23 -0300 Subject: [PATCH 03/16] fix(forms): update Input call sites for the typed component Drop the bogus DOM InputEvent/KeyboardEvent annotations on Input handlers (React infers the correct synthetic events) and fix a few loose call sites surfaced by typing: JSX passed to the string title, readonly->readOnly, non-boolean isValid, and a boolean value. Co-Authored-By: Claude Opus 4.8 --- frontend/web/components/BreadcrumbSeparator.tsx | 16 +++++++--------- .../web/components/ChangeRequestsSetting.tsx | 2 +- frontend/web/components/GroupSelect.tsx | 2 +- frontend/web/components/PermissionsTabs.tsx | 8 ++------ frontend/web/components/RolesSelect.tsx | 2 +- .../web/components/import-export/ImportPage.tsx | 4 +--- .../inspect-permissions/InspectPermissions.tsx | 4 +--- .../inspect-permissions/ProjectPermissions.tsx | 2 +- .../modals/CreateSegmentRulesTabForm.tsx | 6 +++--- .../web/components/pages/GitHubSetupPage.tsx | 8 ++------ .../components/pages/UsersAndPermissionsPage.tsx | 4 ++-- .../components/pages/sdk-keys/SDKKeysPage.tsx | 1 - .../Rule/components/RuleConditionValueInput.tsx | 4 ++-- .../web/components/tables/TableFilterOptions.tsx | 4 ++-- .../web/components/tables/TableSearchFilter.tsx | 2 +- .../web/components/tables/TableTagFilter.tsx | 2 +- frontend/web/components/tags/AddEditTags.tsx | 4 +--- 17 files changed, 29 insertions(+), 46 deletions(-) diff --git a/frontend/web/components/BreadcrumbSeparator.tsx b/frontend/web/components/BreadcrumbSeparator.tsx index 1bee94c62a4e..bbd83e2b0127 100644 --- a/frontend/web/components/BreadcrumbSeparator.tsx +++ b/frontend/web/components/BreadcrumbSeparator.tsx @@ -198,7 +198,7 @@ const BreadcrumbSeparator: FC = ({ ) const navigateOrganisations = ( - e: KeyboardEvent, + e: React.KeyboardEvent, organisations: Organisation[], ) => { const currentIndex = organisations @@ -212,7 +212,7 @@ const BreadcrumbSeparator: FC = ({ } const getNewIndex = ( - e: KeyboardEvent, + e: React.KeyboardEvent, currentIndex: number, items: any[] | undefined, go: (item: any) => void, @@ -237,7 +237,7 @@ const BreadcrumbSeparator: FC = ({ return -1 } - const navigateProjects = (e: KeyboardEvent) => { + const navigateProjects = (e: React.KeyboardEvent) => { const currentIndex = projects ? projects.findIndex((v) => `${v.id}` === `${hoveredProject}`) : -1 @@ -334,10 +334,8 @@ const BreadcrumbSeparator: FC = ({ > - navigateOrganisations(e, organisations) - } - onChange={(e: KeyboardEvent) => { + onKeyDown={(e) => navigateOrganisations(e, organisations)} + onChange={(e) => { setOrganisationSearch(Utils.safeParseEventValue(e)) }} search @@ -390,11 +388,11 @@ const BreadcrumbSeparator: FC = ({ )} > { + onChange={(e) => { setProjectSearch(Utils.safeParseEventValue(e)) }} autoFocus={focus === 'project'} - onKeyDown={(e: KeyboardEvent) => navigateProjects(e)} + onKeyDown={(e) => navigateProjects(e)} search className='full-width' inputClassName='border-0 bg-transparent border-bottom-1' diff --git a/frontend/web/components/ChangeRequestsSetting.tsx b/frontend/web/components/ChangeRequestsSetting.tsx index fe886247c38e..a4c7141a66a5 100644 --- a/frontend/web/components/ChangeRequestsSetting.tsx +++ b/frontend/web/components/ChangeRequestsSetting.tsx @@ -46,7 +46,7 @@ const ChangeRequestsSetting: FC = ({ name='env-name' disabled={isLoading} min={0} - onChange={(e: InputEvent) => { + onChange={(e) => { if (!Utils.safeParseEventValue(e)) return onChange(parseInt(Utils.safeParseEventValue(e))) }} diff --git a/frontend/web/components/GroupSelect.tsx b/frontend/web/components/GroupSelect.tsx index c2df9148f6fa..87f18bba4607 100644 --- a/frontend/web/components/GroupSelect.tsx +++ b/frontend/web/components/GroupSelect.tsx @@ -44,7 +44,7 @@ const GroupSelect: FC = ({ setFilter(Utils.safeParseEventValue(e))} + onChange={(e) => setFilter(Utils.safeParseEventValue(e))} className='full-width mb-2' placeholder='Type or choose a Group' search diff --git a/frontend/web/components/PermissionsTabs.tsx b/frontend/web/components/PermissionsTabs.tsx index 3857234bb415..4748cace9933 100644 --- a/frontend/web/components/PermissionsTabs.tsx +++ b/frontend/web/components/PermissionsTabs.tsx @@ -110,9 +110,7 @@ const PermissionsTabs: FC = ({ type='text' className='ml-3' value={searchProject} - onChange={(e: InputEvent) => - setSearchProject(Utils.safeParseEventValue(e)) - } + onChange={(e) => setSearchProject(Utils.safeParseEventValue(e))} size='small' placeholder='Search Projects' search @@ -142,9 +140,7 @@ const PermissionsTabs: FC = ({ type='text' className='ml-3' value={searchEnv} - onChange={(e: InputEvent) => - setSearchEnv(Utils.safeParseEventValue(e)) - } + onChange={(e) => setSearchEnv(Utils.safeParseEventValue(e))} size='small' placeholder='Search Environments' search diff --git a/frontend/web/components/RolesSelect.tsx b/frontend/web/components/RolesSelect.tsx index 737fd3abee58..f98b2327bae4 100644 --- a/frontend/web/components/RolesSelect.tsx +++ b/frontend/web/components/RolesSelect.tsx @@ -44,7 +44,7 @@ const RoleSelect: FC = ({ setFilter(Utils.safeParseEventValue(e))} + onChange={(e) => setFilter(Utils.safeParseEventValue(e))} className='full-width mb-2' placeholder='Type or choose a Role' search diff --git a/frontend/web/components/import-export/ImportPage.tsx b/frontend/web/components/import-export/ImportPage.tsx index 415182fc7621..f4271b284248 100644 --- a/frontend/web/components/import-export/ImportPage.tsx +++ b/frontend/web/components/import-export/ImportPage.tsx @@ -130,9 +130,7 @@ const ImportPage: FC = ({ projectId, projectName }) => { - setLDKey(Utils.safeParseEventValue(e)) - } + onChange={(e) => setLDKey(Utils.safeParseEventValue(e))} type='text' placeholder='My LaunchDarkly key' /> diff --git a/frontend/web/components/inspect-permissions/InspectPermissions.tsx b/frontend/web/components/inspect-permissions/InspectPermissions.tsx index ecb8e7f31117..09052fb06e6a 100644 --- a/frontend/web/components/inspect-permissions/InspectPermissions.tsx +++ b/frontend/web/components/inspect-permissions/InspectPermissions.tsx @@ -81,9 +81,7 @@ const InspectPermissions: FC = ({ type='text' className='ml-3' value={searchEnv} - onChange={(e: InputEvent) => - setSearchEnv(Utils.safeParseEventValue(e)) - } + onChange={(e) => setSearchEnv(Utils.safeParseEventValue(e))} size='small' placeholder='Search Environments' search diff --git a/frontend/web/components/inspect-permissions/ProjectPermissions.tsx b/frontend/web/components/inspect-permissions/ProjectPermissions.tsx index 1b9c4f40d274..89ab477fabe4 100644 --- a/frontend/web/components/inspect-permissions/ProjectPermissions.tsx +++ b/frontend/web/components/inspect-permissions/ProjectPermissions.tsx @@ -26,7 +26,7 @@ const ProjectPermissions = ({ userId }: { userId?: number }) => { type='text' className='ml-3=' value={searchProject} - onChange={(e: InputEvent) => { + onChange={(e) => { setSearchProject(Utils.safeParseEventValue(e)) }} size='small' diff --git a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx index aa0f8bc174ce..bf48efcb810f 100644 --- a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx +++ b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx @@ -139,7 +139,7 @@ const CreateSegmentRulesTabForm: React.FC = ({ id='segmentID' maxLength={SEGMENT_ID_MAXLENGTH} value={name} - onChange={(e: InputEvent) => { + onChange={(e) => { setValueChanged(true) setName( Format.enumeration @@ -147,7 +147,7 @@ const CreateSegmentRulesTabForm: React.FC = ({ .toLowerCase(), ) }} - isValid={name && name.length} + isValid={!!(name && name.length)} type='text' placeholder='E.g. power_users' /> @@ -162,7 +162,7 @@ const CreateSegmentRulesTabForm: React.FC = ({ name: 'featureDesc', readOnly: !!identity || readOnly, }} - onChange={(e: InputEvent) => { + onChange={(e: React.ChangeEvent) => { setValueChanged(true) setDescription(Utils.safeParseEventValue(e)) }} diff --git a/frontend/web/components/pages/GitHubSetupPage.tsx b/frontend/web/components/pages/GitHubSetupPage.tsx index faa6974a3559..299c6a17134a 100644 --- a/frontend/web/components/pages/GitHubSetupPage.tsx +++ b/frontend/web/components/pages/GitHubSetupPage.tsx @@ -159,12 +159,8 @@ const GitHubSetupPage: FC = ({ location }) => { - setRepositoryOwner(Utils.safeParseEventValue(e)) - } + name='repositoryOwner' + onChange={(e) => setRepositoryOwner(Utils.safeParseEventValue(e))} disabled type='text' title={'Repository Owner'} diff --git a/frontend/web/components/pages/UsersAndPermissionsPage.tsx b/frontend/web/components/pages/UsersAndPermissionsPage.tsx index e958bd489964..064c6b3327d3 100644 --- a/frontend/web/components/pages/UsersAndPermissionsPage.tsx +++ b/frontend/web/components/pages/UsersAndPermissionsPage.tsx @@ -234,6 +234,7 @@ const UsersAndPermissionsInner: FC = ({ for your plan.{' '} {usedSeats && ( <> + {/* eslint-disable-next-line no-nested-ternary */} {overSeats && (!verifySeatsLimit || !autoSeats) ? ( @@ -347,8 +348,7 @@ const UsersAndPermissionsInner: FC = ({ data-test='invite-link' inputClassName='input input--wide' type='text' - readonly='readonly' - title={

Link

} + readOnly placeholder='Link' size='small' /> diff --git a/frontend/web/components/pages/sdk-keys/SDKKeysPage.tsx b/frontend/web/components/pages/sdk-keys/SDKKeysPage.tsx index 604857c6b1ef..74d94ceee9a6 100644 --- a/frontend/web/components/pages/sdk-keys/SDKKeysPage.tsx +++ b/frontend/web/components/pages/sdk-keys/SDKKeysPage.tsx @@ -53,7 +53,6 @@ const SDKKeysPage: FC = () => { value={environmentId} inputClassName='input input--wide' type='text' - title={

Client-side Environment Key

} placeholder='Client-side Environment Key' /> diff --git a/frontend/web/components/segments/Rule/components/RuleConditionValueInput.tsx b/frontend/web/components/segments/Rule/components/RuleConditionValueInput.tsx index 884aedaca4a4..418b46074a90 100644 --- a/frontend/web/components/segments/Rule/components/RuleConditionValueInput.tsx +++ b/frontend/web/components/segments/Rule/components/RuleConditionValueInput.tsx @@ -112,13 +112,13 @@ const RuleConditionValueInput: React.FC = ({ data-test={props['data-test']} name='rule-condition-value-input' aria-label='Rule condition value input' - value={value} + value={typeof value === 'boolean' ? String(value) : value} className='w-100' inputClassName={ showIcon ? `pr-5 ${hasWarning ? 'border-warning' : ''}` : '' } style={{ width: '100%' }} - onChange={(e: InputEvent) => { + onChange={(e) => { const value = Utils.safeParseEventValue(e) onChange?.(value) }} diff --git a/frontend/web/components/tables/TableFilterOptions.tsx b/frontend/web/components/tables/TableFilterOptions.tsx index bbce031fdd37..86aa64310584 100644 --- a/frontend/web/components/tables/TableFilterOptions.tsx +++ b/frontend/web/components/tables/TableFilterOptions.tsx @@ -1,7 +1,7 @@ import React, { FC, ReactNode, useMemo, useState } from 'react' import InlineModal from 'components/InlineModal' import { IonIcon } from '@ionic/react' -import { caretDown, search } from 'ionicons/icons' +import { caretDown } from 'ionicons/icons' import classNames from 'classnames' import TableFilterItem from './TableFilterItem' import Input from 'components/base/forms/Input' @@ -82,7 +82,7 @@ const TableFilter: FC = ({
{ + onChange={(e) => { setFilter(Utils.safeParseEventValue(e)) }} className='full-width' diff --git a/frontend/web/components/tables/TableSearchFilter.tsx b/frontend/web/components/tables/TableSearchFilter.tsx index e58833b3f663..31981fba4ba5 100644 --- a/frontend/web/components/tables/TableSearchFilter.tsx +++ b/frontend/web/components/tables/TableSearchFilter.tsx @@ -17,7 +17,7 @@ const TableSearchFilter: FC = ({ onChange, value }) => { return ( { + onChange={(e) => { const v = Utils.safeParseEventValue(e) setLocalValue(v) debouncedOnChange(v) diff --git a/frontend/web/components/tables/TableTagFilter.tsx b/frontend/web/components/tables/TableTagFilter.tsx index 387c8f110290..8458ec01083f 100644 --- a/frontend/web/components/tables/TableTagFilter.tsx +++ b/frontend/web/components/tables/TableTagFilter.tsx @@ -104,7 +104,7 @@ const TableTagFilter: FC = ({
{ + onChange={(e) => { setFilter(Utils.safeParseEventValue(e)) }} className='full-width' diff --git a/frontend/web/components/tags/AddEditTags.tsx b/frontend/web/components/tags/AddEditTags.tsx index 774a048e491f..48a32447b7c5 100644 --- a/frontend/web/components/tags/AddEditTags.tsx +++ b/frontend/web/components/tags/AddEditTags.tsx @@ -169,9 +169,7 @@ const AddEditTags: FC = ({ submit() } }} - onChange={(e: InputEvent) => - setFilter(Utils.safeParseEventValue(e)) - } + onChange={(e) => setFilter(Utils.safeParseEventValue(e))} size='xSmall' className='full-width' placeholder='Search tags...' From 3f8c7e6abe2c5131def3cdb4eca7c0b410fd7d6d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 12 Jun 2026 08:32:40 -0300 Subject: [PATCH 04/16] refactor: import KeyboardEvent from react in BreadcrumbSeparator Use the named React KeyboardEvent (matching Input.tsx) instead of the React.KeyboardEvent namespace form. Co-Authored-By: Claude Opus 4.8 --- frontend/web/components/BreadcrumbSeparator.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/BreadcrumbSeparator.tsx b/frontend/web/components/BreadcrumbSeparator.tsx index bbd83e2b0127..8162ad01e72e 100644 --- a/frontend/web/components/BreadcrumbSeparator.tsx +++ b/frontend/web/components/BreadcrumbSeparator.tsx @@ -1,4 +1,11 @@ -import React, { FC, ReactNode, useEffect, useRef, useState } from 'react' +import React, { + FC, + KeyboardEvent, + ReactNode, + useEffect, + useRef, + useState, +} from 'react' import { IonIcon } from '@ionic/react' import { checkmarkCircle, chevronDown, chevronUp } from 'ionicons/icons' import InlineModal from './InlineModal' @@ -198,7 +205,7 @@ const BreadcrumbSeparator: FC = ({ ) const navigateOrganisations = ( - e: React.KeyboardEvent, + e: KeyboardEvent, organisations: Organisation[], ) => { const currentIndex = organisations @@ -212,7 +219,7 @@ const BreadcrumbSeparator: FC = ({ } const getNewIndex = ( - e: React.KeyboardEvent, + e: KeyboardEvent, currentIndex: number, items: any[] | undefined, go: (item: any) => void, @@ -237,7 +244,7 @@ const BreadcrumbSeparator: FC = ({ return -1 } - const navigateProjects = (e: React.KeyboardEvent) => { + const navigateProjects = (e: KeyboardEvent) => { const currentIndex = projects ? projects.findIndex((v) => `${v.id}` === `${hoveredProject}`) : -1 From a6adb9e6bc2de665029132a04dd2d4042e888166 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 12 Jun 2026 08:34:10 -0300 Subject: [PATCH 05/16] feat(forms): add FieldLabel and FieldError primitives FieldLabel wires a label to its control (+ required indicator + an info tooltip via the shared LabelWithTooltip); FieldError renders an inline per-field message (counterpart to the ErrorMessage banner). Widen LabelWithTooltip's label to ReactNode. With Storybook stories. Co-Authored-By: Claude Opus 4.8 --- .../components/FieldError.stories.tsx | 29 ++++++++++++ .../components/FieldLabel.stories.tsx | 33 +++++++++++++ .../web/components/base/LabelWithTooltip.tsx | 4 +- .../web/components/base/forms/FieldError.tsx | 30 ++++++++++++ .../web/components/base/forms/FieldLabel.tsx | 46 +++++++++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 frontend/documentation/components/FieldError.stories.tsx create mode 100644 frontend/documentation/components/FieldLabel.stories.tsx create mode 100644 frontend/web/components/base/forms/FieldError.tsx create mode 100644 frontend/web/components/base/forms/FieldLabel.tsx diff --git a/frontend/documentation/components/FieldError.stories.tsx b/frontend/documentation/components/FieldError.stories.tsx new file mode 100644 index 000000000000..bb22af48a9a1 --- /dev/null +++ b/frontend/documentation/components/FieldError.stories.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import FieldError from 'components/base/forms/FieldError' + +const meta: Meta = { + component: FieldError, + parameters: { layout: 'padded' }, + title: 'Components/Forms/FieldError', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => , +} + +export const RichMessage: Story = { + render: () => ( + + Must be a valid email address. + + } + /> + ), +} diff --git a/frontend/documentation/components/FieldLabel.stories.tsx b/frontend/documentation/components/FieldLabel.stories.tsx new file mode 100644 index 000000000000..acfacaa19bd2 --- /dev/null +++ b/frontend/documentation/components/FieldLabel.stories.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import FieldLabel from 'components/base/forms/FieldLabel' + +const meta: Meta = { + component: FieldLabel, + parameters: { layout: 'padded' }, + title: 'Components/Forms/FieldLabel', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => Email, +} + +export const Required: Story = { + render: () => ( + + Email + + ), +} + +export const WithTooltip: Story = { + render: () => ( + + Email + + ), +} diff --git a/frontend/web/components/base/LabelWithTooltip.tsx b/frontend/web/components/base/LabelWithTooltip.tsx index 9b4fefa6b4a4..d15e289c826b 100644 --- a/frontend/web/components/base/LabelWithTooltip.tsx +++ b/frontend/web/components/base/LabelWithTooltip.tsx @@ -1,8 +1,8 @@ import Icon from 'components/icons/Icon' -import { FC } from 'react' +import { FC, ReactNode } from 'react' interface LabelWithTooltipProps { - label: string + label: ReactNode tooltip?: string } diff --git a/frontend/web/components/base/forms/FieldError.tsx b/frontend/web/components/base/forms/FieldError.tsx new file mode 100644 index 000000000000..932013109660 --- /dev/null +++ b/frontend/web/components/base/forms/FieldError.tsx @@ -0,0 +1,30 @@ +import React, { FC, ReactNode } from 'react' +import cn from 'classnames' + +interface FieldErrorProps { + // The field-level error to show; renders nothing when falsy. + error?: ReactNode + // Set so the control can reference it via aria-describedby. + id?: string + className?: string +} + +// Inline, per-field validation message shown beneath a control. The small-text +// counterpart to ErrorMessage (which is a banner alert for API/form-level +// errors) — use FieldError for a single field's validation. +const FieldError: FC = ({ className, error, id }) => { + if (!error) { + return null + } + return ( + + {error} + + ) +} + +export default FieldError diff --git a/frontend/web/components/base/forms/FieldLabel.tsx b/frontend/web/components/base/forms/FieldLabel.tsx new file mode 100644 index 000000000000..caff45cba86e --- /dev/null +++ b/frontend/web/components/base/forms/FieldLabel.tsx @@ -0,0 +1,46 @@ +import React, { FC, ReactNode } from 'react' +import cn from 'classnames' +import LabelWithTooltip from 'components/base/LabelWithTooltip' + +interface FieldLabelProps { + // Associates the label with its control; required for accessibility. + htmlFor?: string + children: ReactNode + // Shows a danger asterisk after the label. + required?: boolean + // When set, an info icon follows the label and reveals this text on hover. + tooltip?: string + className?: string +} + +// The label for a form field — wires `htmlFor` to the control, with an optional +// required indicator and an info-icon tooltip. The tooltip is rendered by the +// shared LabelWithTooltip (which uses the DS Tooltip), not hand-rolled here. +const FieldLabel: FC = ({ + children, + className, + htmlFor, + required, + tooltip, +}) => ( + +) + +export default FieldLabel From 5bfc3f6f324f1af1fc84b7c52b1164633e26dc8c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 12 Jun 2026 08:40:28 -0300 Subject: [PATCH 06/16] refactor(forms): tokenise input colours in _input.scss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy SCSS colour vars on the input field rules with semantic tokens that flip per theme: borders -> --color-border-default/strong/action/ danger/disabled, text -> --color-text-default/secondary/tertiary/disabled, backgrounds -> --color-surface-default. Datepicker/checkbox colours and the _forms.scss input rules are left for the follow-up decomposition. Note: the hover border now maps to --color-border-strong (.24) where it was $basic-alpha-48 (.48) — slightly subtler; no exact .48 token exists. Co-Authored-By: Claude Opus 4.8 --- frontend/web/styles/components/_input.scss | 64 +++++++++++----------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/frontend/web/styles/components/_input.scss b/frontend/web/styles/components/_input.scss index 104bf6c6f7ce..8df5a011e6b0 100644 --- a/frontend/web/styles/components/_input.scss +++ b/frontend/web/styles/components/_input.scss @@ -4,21 +4,21 @@ textarea { width: 100%; height: $textarea-height; outline: none; - border: 1px solid $input-border-color; + border: 1px solid var(--color-border-default); border-radius: $border-radius; - color: $body-color; + color: var(--color-text-default); line-height: $line-height-lg; padding: $input-padding; font-weight: $input-font-weight; &::placeholder { - color: $input-placeholder-color !important; + color: var(--color-text-tertiary) !important; font-weight: normal; } &:hover { - border-color: $basic-alpha-48; + border-color: var(--color-border-strong); } &:focus { - border-color: $primary; + border-color: var(--color-border-action); } &.textarea-lg { padding: $input-padding-lg; @@ -33,7 +33,7 @@ textarea { .input-container.input-underline { input.input { border: none; - border-bottom: 1px solid $input-border-color; + border-bottom: 1px solid var(--color-border-default); border-radius: 0; background-color: transparent; padding-left: 8px; @@ -41,11 +41,11 @@ textarea { // _forms.scss re-applies a full border shorthand on hover/focus. &:hover { border: none; - border-bottom: 1px solid $basic-alpha-48; + border-bottom: 1px solid var(--color-border-strong); } &:focus { border: none; - border-bottom: 1px solid $primary; + border-bottom: 1px solid var(--color-border-action); } } input[type='number'].input { @@ -65,15 +65,15 @@ textarea { .dark .input-container.input-underline { input.input { border: none; - border-bottom: 1px solid $white-alpha-16; + border-bottom: 1px solid var(--color-border-default); background-color: transparent; &:hover { border: none; - border-bottom: 1px solid $white-alpha-48; + border-bottom: 1px solid var(--color-border-strong); } &:focus { border: none; - border-bottom: 1px solid $primary; + border-bottom: 1px solid var(--color-border-action); } } } @@ -88,7 +88,7 @@ textarea { background-color: transparent !important; &.invalid hr, &.invalid hr.highlight { - border-color: $alert-danger-border-color; + border-color: var(--color-border-danger); } label { @@ -108,7 +108,7 @@ textarea { input[type='text'], input[type='password'] { width: 100%; - border: 1px solid $input-border-color; + border: 1px solid var(--color-border-default); outline: none; box-shadow: none; background-image: none; @@ -118,14 +118,14 @@ textarea { border-radius: $border-radius; &:read-only { - color: #777; + color: var(--color-text-secondary); } &:disabled { - border: 1px solid $basic-alpha-8; - color: $text-icon-light-grey; + border: 1px solid var(--color-border-disabled); + color: var(--color-text-disabled); & + .input-icon-right { path { - fill: $text-icon-light-grey; + fill: var(--color-text-disabled); opacity: $btn-disabled-opacity; } } @@ -147,9 +147,9 @@ textarea { background-image: none; -webkit-appearance: none; background-image: none; - color: rgba(0, 0, 0, 0.870588); + color: var(--color-text-default); height: 100%; - background-color: $input-bg; + background-color: var(--color-surface-default); &::-webkit-input-placeholder { font-family: $font-family; font-weight: 400; @@ -159,7 +159,7 @@ textarea { hr { border-bottom-width: 1px; border-style: none none solid; - border-color: $input-border-color; + border-color: var(--color-border-default); bottom: 8px; box-sizing: content-box; margin: 0px; @@ -167,14 +167,14 @@ textarea { width: 100%; &.highlight { border-bottom-width: 2px; - border-color: $input-border-highlight-color; + border-color: var(--color-border-action); transform: scaleX(0); transition: all 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; } } &.error hr { - border-color: $alert-danger-border-color; + border-color: var(--color-border-danger); } &.error, @@ -191,22 +191,22 @@ textarea { input:-webkit-autofill, textarea:-webkit-autofill, select:-webkit-autofill { - background-color: $input-bg-dark; - color: $body-color-dark; + background-color: var(--color-surface-default); + color: var(--color-text-default); } } .dark textarea { - background-color: $input-bg-dark; - color: $body-color-dark; - border-color: $input-bg-dark; + background-color: var(--color-surface-default); + color: var(--color-text-default); + border-color: var(--color-surface-default); &::placeholder { - color: $input-placeholder-color-dark !important; + color: var(--color-text-tertiary) !important; } &:hover { - border-color: $white-alpha-8; + border-color: var(--color-border-strong); } &:focus { - border-color: $primary; + border-color: var(--color-border-action); } } @@ -247,7 +247,7 @@ textarea { } } .react-datepicker-time__input input { - border: 1px solid $input-border-color; + border: 1px solid var(--color-border-default); padding: 2px; border-radius: $border-radius; color: white; @@ -259,7 +259,7 @@ textarea { } .dark { .react-datepicker-time__input input { - border: 1px solid $input-border-color; + border: 1px solid var(--color-border-default); padding: 2px; border-radius: $border-radius; color: white; From 418973aef9b3e6cb6a4f5c7ee48a6c9f9e51b1e8 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 12 Jun 2026 08:47:09 -0300 Subject: [PATCH 07/16] refactor(forms): tokenise input colours in _forms.scss The active .input-container input.input rule (light + dark override) lived in _forms.scss, so tokenise it to match _input.scss: borders -> --color-border-default/strong/action/danger/success/disabled, text -> --color-text-default/tertiary/disabled, bg -> --color-surface-default. The dark override is kept (not removed) so dark hover/focus stay action- coloured as before, rather than inheriting the light rule's --color-border- strong hover. Non-input rules in this file (.label-switch, label) are untouched pending the _forms.scss decomposition. Co-Authored-By: Claude Opus 4.8 --- frontend/web/styles/project/_forms.scss | 37 +++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/frontend/web/styles/project/_forms.scss b/frontend/web/styles/project/_forms.scss index a304f2d521d1..c619243d7577 100644 --- a/frontend/web/styles/project/_forms.scss +++ b/frontend/web/styles/project/_forms.scss @@ -27,24 +27,27 @@ .dark { .input-container { input.input { - border: 1px solid $input-hover-border-color-dark; - background-color: $input-bg-dark; - color: $text-icon-light; + border: 1px solid var(--color-border-default); + background-color: var(--color-surface-default); + color: var(--color-text-default); + // Dark hover/focus stay action-coloured (was $primary) — the light + // rule uses --color-border-strong on hover, so this override keeps + // the existing dark behaviour rather than inheriting it. &:hover { - border: 1px solid $input-focus-border-color-dark; + border: 1px solid var(--color-border-action); } &:focus { - border: 1px solid $input-focus-border-color-dark; + border: 1px solid var(--color-border-action); } &::placeholder { - color: $input-placeholder-color-dark; + color: var(--color-text-tertiary); } &:disabled { - border: 1px solid $black-alpha-32; - color: $text-icon-light-grey; + border: 1px solid var(--color-border-disabled); + color: var(--color-text-disabled); & + .input-icon-right { path { - fill: $text-icon-light-grey; + fill: var(--color-text-disabled); opacity: $btn-disabled-opacity; } } @@ -60,27 +63,27 @@ .react-datepicker-wrapper .react-datepicker__input-container { &.invalid { input.input { - border-color: $danger !important; + border-color: var(--color-border-danger) !important; } } &.success { input.input { - border-color: $success !important; + border-color: var(--color-border-success) !important; } } input.input { - border: 1px solid $input-border-color; - background-color: $input-bg; + border: 1px solid var(--color-border-default); + background-color: var(--color-surface-default); @include transition(all 200ms); &:hover { - border: 1px solid $basic-alpha-48; + border: 1px solid var(--color-border-strong); } &:focus { - border: 1px solid $primary; + border: 1px solid var(--color-border-action); } border-radius: $border-radius; - color: $body-color; + color: var(--color-text-default); height: $input-height; line-height: $line-height-lg; padding: 12px 12px 12px 16px; @@ -89,7 +92,7 @@ } &::placeholder { - color: $input-placeholder-color; + color: var(--color-text-tertiary); font-weight: normal; } From 37c4d6ae3615147282d60b16da9875972ccaba09 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 15 Jun 2026 12:48:01 -0300 Subject: [PATCH 08/16] refactor(forms): fold info-icon tooltip into FieldLabel, drop LabelWithTooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldLabel now renders the info-icon tooltip itself (DS Tooltip) instead of delegating to LabelWithTooltip. LabelWithTooltip had a single real consumer (EnvironmentMetric), which is a display caption — not a form control — so it can't adopt FieldLabel's ) diff --git a/frontend/web/components/metrics/EnvironmentMetric.tsx b/frontend/web/components/metrics/EnvironmentMetric.tsx index 9aaea67760b0..1c351bcb5d33 100644 --- a/frontend/web/components/metrics/EnvironmentMetric.tsx +++ b/frontend/web/components/metrics/EnvironmentMetric.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react' import { Link } from 'react-router-dom' -import LabelWithTooltip from 'components/base/LabelWithTooltip' +import Icon from 'components/icons/Icon' +import Tooltip from 'components/Tooltip' interface EnvironmentMetricProps { label: string value: string | number @@ -14,6 +15,20 @@ const EnvironmentMetric: FC = ({ tooltip, value, }) => { + const labelContent = ( + <> + {label} + {tooltip && ( + } + place='top' + titleClassName='cursor-pointer ml-1' + > + {tooltip} + + )} + + ) return (
= ({
{link ? ( - + {labelContent} ) : ( -

- -

+

{labelContent}

)}

{value}

From 1993d97f35233c865c54bb2a971ab2dae25ae775 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 15 Jun 2026 14:05:37 -0300 Subject: [PATCH 09/16] refactor(forms): migrate InputGroup to TypeScript Convert InputGroup.js -> InputGroup.tsx as a function component (React 19 ref-as-prop; focus() via useImperativeHandle, useId for the field id). Export InputSize from Input for the size prop. The legacy escape-hatch props (isValid, onChange, inputProps) stay permissive to match the existing usage across ~62 consumers, and dead top-level props that the old class never forwarded (name, autocomplete, autoFocus, autoValidate, search, rows) are accepted-but-ignored (@deprecated) to preserve behaviour without a consumer-wide cleanup. Tightening these + removing the dead consumer props is a follow-up. Behaviour-preserving except dropping isValid off the raw