Skip to content

Commit 41d8eba

Browse files
talissoncostaclaude
andcommitted
fix(review): tighten the contract and the format row's markup
From a review pass over the split: - `label` is required, since the accessible name is the point. That caught the story wrapper typed as `Record<string, any>`; it takes the component's own props now. ReactNode admits null, so the runtime guard still does the work of not rendering a nameless editor. - `language` is derived from the prop, the user's pick, then detection. It was read into useState on mount, so a caller changing the prop kept the old format for highlighting and validation. No caller does that today. - Validity is only computed alongside the format row, so a caller that pins the format no longer runs a DOMParser parse per keystroke. - LanguageValidation renders beside the format button rather than inside it: Tooltip mounts a div, which a button cannot contain, and it joined the button's accessible name while shown. - The SAML download control is a BareButton with an aria-label, wrapped by its tooltip rather than wrapping it. It was a clickable div with no keyboard path and no name. - An invalid-JSON story is back, so the danger tone has visual coverage again. The format is clicked rather than pinned, since a pinned editor has no row to render the warning against. - The helper comment claimed the multivariate label reads "Control Value <weight>%". It does not: the weight chip is a labelAfter sibling, outside the label. Named the `string | Locator` union too. Verified in Storybook: a value arriving after mount still detects as json, clicking .xml overrides it and raises the warning, a pinned format still hides the row, and the warning renders red beside the active label. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eb04dff commit 41d8eba

6 files changed

Lines changed: 87 additions & 48 deletions

File tree

frontend/documentation/components/ValueEditor.stories.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
22
import type { Meta, StoryObj } from 'storybook'
33

44
import Constants from 'common/constants'
5-
import ValueEditor from 'components/ValueEditor'
5+
import ValueEditor, { ValueEditorProps } from 'components/ValueEditor'
66
import ControlWeightChip from 'components/mv/ControlWeightChip'
77

88
const meta: Meta = {
@@ -13,11 +13,17 @@ export default meta
1313

1414
type Story = StoryObj
1515

16+
// The real props, so a story cannot drift from the component's contract.
17+
type InteractiveProps = Omit<ValueEditorProps, 'value' | 'onChange'> & {
18+
initialValue?: string
19+
width?: number
20+
}
21+
1622
const Interactive = ({
1723
initialValue = '',
1824
width = 640,
1925
...props
20-
}: Record<string, any>) => {
26+
}: InteractiveProps) => {
2127
const [value, setValue] = useState(initialValue)
2228
return (
2329
<div style={{ maxWidth: width, padding: 16 }}>
@@ -75,6 +81,20 @@ export const ValueArrivesAfterMount: Story = {
7581
render: () => <LateLoading />,
7682
}
7783

84+
// The danger tone, which no other story shows. The format has to be chosen
85+
// rather than pinned: a pinned editor has no format row to render the warning
86+
// against.
87+
export const InvalidJson: Story = {
88+
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
89+
const buttons = canvasElement.querySelectorAll('.select-language button')
90+
const json = Array.from(buttons).find(
91+
(button) => button.textContent?.trim() === '.json',
92+
)
93+
;(json as HTMLButtonElement | undefined)?.click()
94+
},
95+
render: () => <Interactive label='Value' initialValue='{ "colour": ' />,
96+
}
97+
7898
export const CodeMedium: Story = {
7999
render: () => (
80100
<Interactive

frontend/e2e/helpers/e2e-helpers.playwright.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { Locator, Page, expect } from '@playwright/test';
2+
3+
// A CSS/data-test string, or a Locator built from role and accessible name.
4+
type SelectorOrLocator = string | Locator;
25
import { LONG_TIMEOUT, SHORT_TIMEOUT, byId, log, logUsingLastSection, getFlagsmith } from './utils.playwright';
36

47
// Re-export for backwards compatibility
@@ -19,8 +22,9 @@ export class E2EHelpers {
1922
constructor(private page: Page) {}
2023

2124
// The value editors are selected by role and accessible name rather than a
22-
// data-test. The feature value label switches to "Control Value <weight>%"
23-
// once the feature has variations, hence the alternation.
25+
// data-test. The label reads "Control Value" once the feature has variations,
26+
// hence the alternation; the weight chip is a labelAfter sibling, so it stays
27+
// out of the accessible name.
2428
featureValueField(): Locator {
2529
return this.page
2630
.locator('#create-feature-modal')
@@ -44,7 +48,7 @@ export class E2EHelpers {
4448
return await this.page.locator(byId(selector)).count() > 0;
4549
}
4650

47-
async setText(selector: string | Locator, text: string) {
51+
async setText(selector: SelectorOrLocator, text: string) {
4852
logUsingLastSection(`Set text ${selector} : ${text}`);
4953
const element = typeof selector === 'string' ? this.page.locator(selector).first() : selector;
5054
await element.waitFor({ state: 'visible', timeout: LONG_TIMEOUT });
@@ -54,7 +58,7 @@ export class E2EHelpers {
5458
}
5559
}
5660

57-
async waitForElementVisible(selector: string | Locator, timeout: number = LONG_TIMEOUT) {
61+
async waitForElementVisible(selector: SelectorOrLocator, timeout: number = LONG_TIMEOUT) {
5862
logUsingLastSection(`Waiting element visible ${selector}`);
5963
const element = typeof selector === 'string' ? this.page.locator(selector).first() : selector;
6064
await element.waitFor({

frontend/e2e/tests/change-request-test.pw.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ test.describe('Change Request Tests', () => {
1515
featureValueField,
1616
assertChangeRequestCount,
1717
approveChangeRequest,
18-
assertInputValue,
19-
closeModal,
18+
closeModal,
2019
createChangeRequest,
2120
createEnvironment,
2221
createRemoteConfig,

frontend/web/components/ValueEditor/ValueEditor.tsx

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import './ValueEditor.scss'
2323
export interface ValueEditorProps {
2424
className?: string
2525
disabled?: boolean
26-
// Rendered as a FieldLabel wired to the editor, so callers cannot get the
27-
// association wrong.
28-
label?: ReactNode
26+
// Required: it is what names the editor. Rendered as a FieldLabel wired to
27+
// it, so callers cannot get the association wrong.
28+
label: ReactNode
2929
// Sits beside the label, outside it, so it stays out of the editor's
3030
// accessible name.
3131
labelAfter?: ReactNode
@@ -49,37 +49,41 @@ const ValueEditor: FC<ValueEditorProps> = ({
4949
onChange,
5050
value,
5151
}) => {
52-
const [language, setLanguage] = useState<ValueEditorLanguage>(
53-
languageProp ?? 'txt',
54-
)
52+
const [picked, setPicked] = useState<ValueEditorLanguage>()
53+
const [detected, setDetected] = useState<ValueEditorLanguage>()
54+
// Derived, not state: a caller that changes `language` has to win on the
55+
// next render, and useState would keep whatever it read on mount.
56+
const language = languageProp ?? picked ?? detected ?? 'txt'
5557
const labelId = useId()
5658
const text = value === undefined || value === null ? '' : `${value}`
5759

5860
// Detection waits for a value rather than running on mount: values load
5961
// after mount, and a mount-only check left JSON rendering as plaintext.
60-
const formatSettled = useRef(!!languageProp)
62+
const detectionDone = useRef(false)
6163

6264
useEffect(() => {
63-
if (formatSettled.current || !text) return
64-
formatSettled.current = true
65+
if (languageProp || picked || detectionDone.current || !text) return
66+
detectionDone.current = true
6567
try {
6668
if (typeof JSON.parse(text) === 'object') {
67-
setLanguage('json')
69+
setDetected('json')
6870
}
6971
} catch (e) {}
70-
}, [text])
71-
72-
const error = useMemo(() => validateValue(language, text), [language, text])
72+
}, [text, languageProp, picked])
7373

74-
const pickLanguage = (next: ValueEditorLanguage) => {
75-
formatSettled.current = true
76-
setLanguage(next)
77-
}
74+
const pickLanguage = (next: ValueEditorLanguage) => setPicked(next)
7875

7976
// A caller that pins the format has nothing to switch, so the row goes, and
8077
// copy goes with it as it always has.
8178
const showControls = !disabled && !languageProp
8279

80+
// Only rendered alongside the format row, so a pinned caller does not need a
81+
// DOMParser run on every keystroke.
82+
const error = useMemo(
83+
() => (showControls ? validateValue(language, text) : false),
84+
[language, text, showControls],
85+
)
86+
8387
return (
8488
<div
8589
className={cx(

frontend/web/components/ValueEditor/components/LanguageSelector.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { FC, MouseEvent } from 'react'
1+
import React, { FC, Fragment, MouseEvent } from 'react'
22
import cx from 'classnames'
33

44
import BareButton from 'components/base/forms/BareButton'
@@ -30,22 +30,26 @@ const LanguageSelector: FC<LanguageSelectorProps> = ({
3030
aria-label='Value format'
3131
>
3232
{LANGUAGES.map((option) => (
33-
<BareButton
34-
key={option}
35-
// The editor is contenteditable: pressing down on a button would blur
36-
// it. preventDefault keeps the caret; the click still fires.
37-
onMouseDown={(e: MouseEvent) => e.preventDefault()}
38-
onClick={() => onChange(option)}
39-
aria-pressed={language === option}
40-
className={cx(option, 'd-flex align-items-center text-secondary', {
41-
active: language === option,
42-
})}
43-
>
44-
{LANGUAGE_LABELS[option]}{' '}
33+
<Fragment key={option}>
34+
<BareButton
35+
// The editor is contenteditable: pressing down on a button would
36+
// blur it. preventDefault keeps the caret; the click still fires.
37+
onMouseDown={(e: MouseEvent) => e.preventDefault()}
38+
onClick={() => onChange(option)}
39+
aria-pressed={language === option}
40+
className={cx(option, 'd-flex align-items-center text-secondary', {
41+
active: language === option,
42+
})}
43+
>
44+
{LANGUAGE_LABELS[option]}
45+
</BareButton>
46+
{/* Beside the active button, not inside it: the tooltip mounts a div,
47+
which a button cannot contain, and it would otherwise join the
48+
button's accessible name while shown. */}
4549
{option !== 'txt' && language === option && (
4650
<LanguageValidation language={option} error={error} />
4751
)}
48-
</BareButton>
52+
</Fragment>
4953
))}
5054
</Row>
5155
)

frontend/web/components/pages/organisation-settings/tabs/sso/saml/modals/CreateSAML.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { FC, useEffect, useState } from 'react'
22
import FieldLabel from 'components/base/forms/FieldLabel'
3+
import BareButton from 'components/base/forms/BareButton'
34
import InputGroup from 'components/base/forms/InputGroup'
45
import Utils from 'common/utils/utils'
56
import Switch from 'components/Switch'
@@ -167,20 +168,27 @@ const CreateSAML: FC<CreateSAML> = ({ organisationId, samlName }) => {
167168
label='IdP metadata XML'
168169
labelAfter={
169170
data?.idp_metadata_xml && (
170-
<div className='clickable' onClick={downloadIDPMetadata}>
171-
<Tooltip
172-
title={
171+
// A button, not a clickable div: this was unreachable by
172+
// keyboard and had no accessible name. The tooltip wraps it
173+
// rather than nesting inside, since it renders a div.
174+
<Tooltip
175+
title={
176+
<BareButton
177+
aria-label='Download IdP metadata'
178+
className='d-inline-flex align-items-center'
179+
onClick={downloadIDPMetadata}
180+
>
173181
<IonIcon
174182
className='icon-action'
175183
icon={cloudDownloadOutline}
176184
style={{ fontSize: '18px' }}
177185
/>
178-
}
179-
place='right'
180-
>
181-
Download IDP Metadata
182-
</Tooltip>
183-
</div>
186+
</BareButton>
187+
}
188+
place='right'
189+
>
190+
Download IdP metadata
191+
</Tooltip>
184192
)
185193
}
186194
className='full-width'

0 commit comments

Comments
 (0)