refactor(value-editor): migrate to TypeScript and split by concern - #8447
refactor(value-editor): migrate to TypeScript and split by concern#8447talissoncosta wants to merge 21 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Team Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughThe change adds a modular Estimated code review effort: 4 (Complex) | ~60 minutes Merge Risk: 🟡 Moderate · up to This migration updates value editing, clipboard behavior, and SAML metadata editing. The IdP metadata download control may be unavailable immediately after creating a SAML configuration, and outstanding frontend contract issues remain, so the change should be resolved before merge. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
9930bc1 to
61e17f1
Compare
b129ad3 to
7f4289b
Compare
7f4289b to
1a381c2
Compare
|
@themis-blindfold review |
⚖️ Themis review: 🟠 Fix before mergeThe editor is now typed, labelled, keyboard-operable, and exercised through the production contenteditable path. One regression gap remains in the browser-only XML validation branch. Completed unit, lint, static-analysis, and three E2E jobs passed; one private-cloud E2E job was still running.
🟠 Majors
⚖️ Acknowledged
📝 Walkthrough
🧪 How to verify
Product take: A solid reliability and accessibility improvement to a heavily reused editor. The remaining XML regression coverage is important because SAML and remote-config users depend on it. 🧭 Assumptions & unverified claimsThe focused Jest command could not run in this checkout because the Jest binary is absent; the completed Unit Tests job passed. A tidier editor deserves one XML safety net · reviewed at db237f4 |
|
@themis-blindfold review |
⚖️ Themis review: 🟠 Fix before mergeThe ValueEditor migration preserves its callers, gives the production contenteditable path browser coverage, and improves its accessible controls. One SAML persistence correction still lacks regression coverage. CI is still in progress; the completed unit, lint, and visual checks passed.
🟠 Majors
⚖️ Acknowledged
📝 Walkthrough
🧪 How to verify
Product take: A solid reliability and accessibility improvement to a frequently used configuration editor; the untested SAML clearing path is the only material gap. 🧭 Assumptions & unverified claims
The editor has finally met the editor in the tests · reviewed at 4fd83c7 |
There was a problem hiding this comment.
Actionable comments posted: 6
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Team
Run ID: 2a1a668a-f712-4dd9-acdf-39ef2d5eb445
📒 Files selected for processing (16)
frontend/common/utils/__tests__/copyToClipboard.test.tsfrontend/common/utils/copyToClipboard.tsfrontend/documentation/components/ValueEditor.stories.tsxfrontend/e2e/helpers/e2e-helpers.playwright.tsfrontend/e2e/tests/change-request-test.pw.tsfrontend/e2e/tests/flag-tests.pw.tsfrontend/e2e/tests/mv-options-tests.pw.tsfrontend/e2e/tests/segment-test.pw.tsfrontend/e2e/tests/versioning-tests.pw.tsfrontend/web/components/Highlight.jsfrontend/web/components/SegmentOverrides.jsfrontend/web/components/ValueEditor/ValueEditor.scssfrontend/web/components/ValueEditor/ValueEditor.tsxfrontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsxfrontend/web/components/mv/VariationValueInput/VariationValueInput.tsxfrontend/web/components/pages/organisation-settings/tabs/sso/saml/modals/CreateSAML.tsx
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.
ValueEditor was a 254-line untyped class with no props contract, which is
how `placeholder` and `readOnly` came to be passed at three call sites and
silently ignored. Renaming the file to .tsx produced 43 strict errors; all
but two were missing interfaces and implicit any.
The two that were not:
- renderValidation() was called with an argument it does not accept
- <Tooltip position='top'> passes a prop TooltipProps does not declare
Both are gone. `value` is typed FlagsmithValue rather than string, which is
what the MV and segment call sites actually pass.
Split into a folder by concern, so each piece is small and the parsing is
testable on its own for the first time:
ValueEditor/
ValueEditor.tsx orchestrates, function component
types.ts language union and display order
validate.ts pure parse checks, no React
components/LanguageSelector the .txt/.json/.xml/.toml/.yaml row
components/LanguageValidation the tick or warning
components/CopyValueButton the copy control
index.ts
Imports are unchanged: the folder resolves where the file used to.
Drop ionicons from this component. We already own `warning` and
`checkmark-circle`, so it is a straight swap. Icon forwards className for
only 12 of its ~100 cases and `warning` hardcodes orange, so the tone class
goes on a wrapper and the icon inherits through currentColor.
Copying no longer goes through polyfill-react-native's Clipboard, which
wraps the deprecated document.execCommand. Extract copyToClipboard out of
the Utils object into common/utils/copyToClipboard, so a component can
reach it without pulling in Utils; Utils keeps the key and delegates, so
its nineteen existing callers are untouched. The Storybook stub for Utils
asks new components not to import it, and this is what that looks like.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chrome puts <parsererror> in the XHTML namespace, so it is an HTMLElement with innerText and the old instanceof check held. Firefox uses its own namespace and returns a plain Element with neither, so malformed XML reported no error and the editor showed a green tick. Our E2E runs on Firefox. Reading textContent off Element works in both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
validate.ts and copyToClipboard.ts are plain .ts with no React, which is the only thing this project can unit test today: jest runs with testEnvironment 'node' and testMatch picks up .ts but not .tsx, and there is no React Testing Library. The xml branch is left as it.todo. It needs DOMParser, which the node environment does not provide, and pulling in jest-environment-jsdom is a wider decision than this file. Writing these caught a wrong assumption: 'a:\n- b\n - c' parses fine, so the yaml failure case now uses an unclosed quote and a bad indent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The .txt/.json/.xml/.toml/.yaml labels were <span onMouseDown>: not focusable, no role, no keyboard path. That is the same defect the copy control had, and leaving four of them beside a fixed fifth made no sense. They are BareButtons now, in a role=group labelled "Value format", with aria-pressed carrying the selection. Keeping mousedown was the reason they were spans. The editor is contenteditable and pressing down on a button blurs it, losing the caret. preventDefault on mousedown stops the blur without stopping the click, so onClick can do the work and the keyboard path comes for free. The styles keyed off the `span` selector, so they move to `.bare-btn`. BareButton resets at zero specificity, so these rules win without having to out-specify it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three label treatments had grown up around one component: FieldLabel at two call sites, a raw <label> at two, a bespoke ValueRowLabel div at three, and nothing at all in the SAML modal. None of them passed htmlFor, and ValueEditor accepted no id, so no version named the editor. ValueEditor now takes `label` and `labelTooltip` and renders the FieldLabel itself, generating both ids so a caller cannot get the association wrong. The editor carries aria-labelledby, and the contenteditable gets role=textbox with aria-multiline, without which a screen reader announces it as plain text and aria-labelledby has nothing to name. That also puts the label and the format buttons in one flex row. They used to be separate layers, the button row absolutely positioned over whatever label the caller happened to render, so nothing stopped a long label running underneath it. The multivariate control value is the widest case (label, weight chip and tooltip); at 380px, narrower than the drawer gets, it now leaves a 56px gap instead of relying on there being room. The copy button moves into a __field wrapper so it stays positioned against the editor rather than the whole component, which now includes the header. Two onChange handlers were annotated as taking a ChangeEvent when ValueEditor has only ever passed a string; safeParseEventValue returns non-events unchanged, which is why nobody noticed. Typed properly and the wrapper dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ValueEditor was the wrong component here. onlyOneLang turned off the format buttons, the copy control and, because validation renders beside the active format label, the XML validation too. What was left was syntax highlighting on a blob users paste and rarely read, bought at the cost of a contenteditable standing in for a form control. InputGroup with textarea gives what the field needs: a real <textarea> with an id, a FieldLabel wired to it with htmlFor, aria-invalid and aria-describedby, FieldError, and a placeholder that is honoured. The call site has been passing one all along that ValueEditor dropped. The download action moves to the footer beside the sibling "Download Service Provider Metadata" button, rather than hanging off the field's label. It also sends the saved config rather than what the field shows, so in the header it read as "download this field" and would have handed you something else once you typed. data-test and name were 'featureValue', copy-pasted from the feature form, so an unrelated field shared a test id. onlyOneLang existed for this one caller, so it goes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The feature drawer rendered "Control Value" with a weight chip; segment overrides rendered the same idea as "Segment Control Value - 50%". Same concept, two treatments, and only one of them a chip. ControlWeightChip renders it once. It uses the canonical Chip rather than the legacy .chip class the feature drawer had inline: the legacy one carries a hand-written .dark block, and new code should not add a 36th usage of what #6606 is trying to retire. The chip sits after the tooltip icon, so the label reads "Control Value (i) 100%". It is a sibling of FieldLabel inside a flex row rather than label content, which leaves FieldLabel alone and keeps the weight out of the editor's accessible name: that name is now a stable "Control Value" instead of shifting with the split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ides web/styles/3rdParty/_hljs.scss is for overriding highlight.js and Pylon's chat widget. About 130 of its 387 lines were first-party ValueEditor styles: the component block, its header, field and copy button, the format row, and code.txt. They now live in ValueEditor.scss beside the component, which drops _hljs.scss to 256 lines of what it says it is. The selectors are unchanged, so specificity and cascade behave as before. Verified in the browser: copy still 18px/12px inside the field and 11px in code-medium, padding still 16px 44px 16px 20px, code-medium still holds 44px, the format buttons still 12px, and code.txt keeps its 14px/500 OpenSans on a white surface. It is the only component stylesheet that imports _variables. The six input sizing values it needs have no token equivalent, and inlining them would lose the link to $input-height that code-medium exists to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ount The language check ran in a mount-only effect behind a disabled exhaustive-deps rule. Values load after mount, and when one did the effect had already returned at `if (!value)`, so a JSON value rendered as plaintext for the life of the editor. It now waits for a value and runs once, tracked by a ref that a manual pick also sets, so detection never overrides a choice. The caller's `language` seeds the state directly and counts as already settled. No lint suppression needed. Alongside, from the review pass: Scope `code.txt`. It was a global selector, so any <code class="txt"> in the app picked up a white surface, a border and 14px OpenSans. Finish moving the styles out of the globals: a `.value-editor` block was still in styles.scss. Its disabled-state cursor and opacity move here; the rule hiding the format row is dead, since the row is no longer rendered while disabled. Drop `editorId`. Nothing referenced it: the label association goes through aria-labelledby, because a <code> is not labelable and htmlFor would do nothing. Highlight's id passthrough goes with it. Import Row and Tooltip rather than reading them off the window, which is why Storybook needed stubs for them. Adds a story for a value that arrives after mount, covering the bug above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The parse check lived inside LanguageValidation, which renders inside LanguageSelector, so validity only existed when the format row did. That is why the SAML field could ask for language='xml' and get no XML validation: hiding the row hid the check with it. ValueEditor computes it now and passes it down, so LanguageValidation is presentational and can be rendered anywhere. Callers can read it too, via onValidityChange. saveFeatureWithValidation still reads 'language-validation-error' off the DOM, so that id stays. Moving it onto the callback needs a decision this change should not make on its own: the DOM query is global, so today a parse error in a segment override also blocks saving the feature value, and a callback has to say which editors gate which save. Also documents why onChange hands back a string rather than a FlagsmithValue: this edits text, and deciding "123" is a number is domain logic that belongs to the caller. Two SegmentOverrides handlers were still annotated as taking events and wrapping in safeParseEventValue, which has been a no-op since ValueEditor started passing strings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layout and spacing move to Bootstrap utilities, the resting colour of the
format buttons to a token utility, matching how the copy button already
works: utility for the resting state, SCSS for hover and active.
Deleted from the SCSS: the __header flex row, the __label block, the
__field position, select-language's margin-left, and the flex on the
format buttons and the validation icon. `.control-label { margin-bottom:
0 }` becomes mb-0 on FieldLabel, which stops this component reaching into
another one's class.
`.active .language-icon` also set margin and opacity. On main that rule
was `.active span`, aimed at the old label spans; nothing sets either
property on the icon, so those two declarations were dead.
What stays needs to: offsets and gaps off Bootstrap's 4px scale, hover
and focus states, the :has() and ancestor selectors, and everything on
the pre/code/hljs elements Highlight renders, where there is no JSX here
to carry a class.
Storybook, light and dark: the header, field and format row keep the same
computed styles and the same bounding boxes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icon carries .text-success or .text-danger, but `.select-language .active .language-icon` set a colour at 0-3-0 against the utility's 0-1-0, and validation only ever renders on the active label, so that rule always won. A parse error showed a warning triangle in body colour. Verified in Storybook: the icon computes to rgb(239, 77, 86) in dark and the danger token in light. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`string | false` appeared at four boundaries: validateValue's return, onValidityChange, and both subcomponents' props. One exported ValueEditorError now covers them, and the icon's tone gets a named type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.hljs` sets `outline: none` and this component suppresses the outline colour on top, for every language. Only `code.txt` restyled its border on focus, so focusing a JSON, XML, TOML or YAML editor showed nothing at all. `.hljs` does carry a border, so focus-visible can recolour it the way inputs do. Also drops `word-break: break-word`, deprecated in favour of `overflow-wrap`, and reuses $font-family rather than repeating its value. Verified in Storybook: focusing the JSON editor takes the border to var(--color-border-action). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stylelint's scss/double-slash-comment-empty-line-before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The E2E ? textarea : Highlight fork was added in 2022 as a TestCafe workaround. Playwright landed in March and drives a contenteditable with fill(), so every E2E assertion about this editor has been running against a textarea that only exists during tests. placeholder and readOnly only ever worked on that textarea, so they go with it, along with the three call sites passing them. Highlight takes role and aria-readonly from the caller rather than deriving them from onChange, so ValueEditor can name its read-only editors while the code blocks that also use Highlight stay unlabelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ValueEditor no longer takes a data-test. Three accessors replace it, and
setText/waitForElementVisible accept a Locator so callers can pass one.
featureValueField() role=textbox, /^(Value|Control Value)/
variationValueField(i) role=textbox, "Variation Value", nth(i)
segmentOverrideValueField(i) scoped to the override, then role+name
The feature value alternation is not cosmetic: the label becomes
"Control Value <weight>%" once a feature has variations.
This also retires a selector that encoded its own value:
data-test={`featureVariationValue${featureStateToValue(value) || index}`}
The id was featureVariationValue1 only while the field was empty, and
became featureVariationValueadded once you typed. The tests passed because
they addressed it before typing.
toHaveValue becomes toHaveText in change-request-test: the editor is a
contenteditable, not a textarea, so there is no value to assert on.
No new type errors in e2e (3 before, 3 after, all pre-existing).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `featureValueField` was page-wide and prefix-matched, so it relied on `Control Value` and VariationOptions' `Control value` differing only in case. Scoped to the drawer and anchored. - `Highlight.shouldComponentUpdate` listed neither `role`, `aria-readonly`, `aria-labelledby`, `disabled` nor `onChange`, so a value editor that turned read-only kept its `contentEditable` and never gained `aria-readonly`. - `ValueEditor.scss` imported `_variables`, which is not variables-only: it emits `.dark hr`, `.bg-*` and a `.fill-*` loop, duplicating them into the component chunk. The seven scalars it needs move to `_metrics.scss`, imported after bootstrap so bootstrap's own rem defaults still win. Component CSS drops from 67 rule blocks to 20 and the global stylesheet compiles to an identical hash. - Dropped the stray `placeholder` on a SegmentOverrides editor that no longer takes one, and three dead destructured E2E helpers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`validate.ts` has unit tests for every format except xml, where both cases are `it.todo`: jest runs in node and `xmlError` needs a real DOMParser, whose parser-error output differs between browsers. Nothing exercised format switching either. Drives it through the drawer instead: malformed XML raises the error against `.xml`, a well-formed document clears it, then the same for `.json`. Closes without saving, so it leaves no data behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swapping the IdP metadata field to InputGroup lost syntax highlighting, which is the reason SAML used ValueEditor in the first place: an administrator pastes a metadata document and has to be able to read it. Reverted, so SAML keeps the editor it has on main. `onlyOneLang` goes instead. A caller that pins `language` has nothing to switch to, so pinning now hides the format row and the copy control, which is all that boolean ever did. The SAML call site loses one prop rather than gaining one. The InvalidJson story went with it: the warning renders against the active format label, so a pinned story has nowhere to show it. The browser test added in this PR covers that path instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
|
@themis-blindfold review |
⚖️ Themis review: ✅ Ship itThe refactor keeps the editor’s callers aligned with its typed, labelled textbox contract, and the real contenteditable path is now exercised by browser coverage. All recorded checks passed, including unit, lint, browser, and E2E suites.
⚖️ Acknowledged
📝 Walkthrough
🧪 How to verify
Product take: A solid accessibility and regression-safety improvement to a widely used configuration control. It is mostly refactoring, but it removes an important gap between test and production editing behaviour. 🧭 Assumptions & unverified claimsNo unverified assumptions or claims. The editor has graduated from its test-only stunt double · reviewed at 41d8eba |
Thanks for submitting a PR! Please check the boxes below:
docs/if required so people know about the feature.Changes
Closes #8478.
ValueEditorwas a 251 line untyped class with no props contract, which is howplaceholderandreadOnlycame to be passed by callers and silently ignored. This migrates it to TypeScript and splits it by concern:The folder resolves where the file used to, so no import changed.
Also in here:
FieldLabel, a raw<label>, a bespoke div), none passedhtmlFor, andValueEditoraccepted noid, so none of them actually named the editor. It now renders theFieldLabelitself and generates both ids. The contenteditable getsrole="textbox"andaria-labelledby.<span onMouseDown>: not focusable, no role, no keyboard path. NowBareButtons in arole="group"labelled "Value format", carryingaria-pressed.mousedownwas the reason they were spans, since pressing down on a button blurs the contenteditable and loses the caret, sopreventDefaultkeeps the caret andonClickdoes the work.web/styles/3rdParty/_hljs.scss, the component's own SCSS is 99 lines.navigator.clipboardinstead ofpolyfill-react-native's wrapper around the deprecatedexecCommand.copyToClipboardmoves tocommon/utils;Utilskeeps the key and delegates, so its existing callers are untouched. Note this narrows the copy button in insecure contexts, wherenavigator.clipboardis undefined: deliberate, since the other 18 callers ofUtils.copyToClipboardalready behaved that way and no deployment we document is served over plain http. If that turns out to be a supported mode it wants fixing for all 19 callers, not here..select-language .active .language-iconout specified.text-danger.ValueEditorrenderedE2E ? <textarea> : <Highlight>, a 2022 TestCafe workaround (Chores/read only feature #1642), so no test at any level touched the contenteditable users type into. The fork goes, and the three value editor selectors move to role and accessible name, which this PR's label work made possible.placeholderandreadOnlyonly ever reached that textarea, so they go with it, along withname.SAML moves to
InputGroup, since it only usedValueEditorto get an XML textarea.How did you test this code?
13 unit tests over
validate.ts(every language's valid and invalid paths) andcopyToClipboard.ts. Writing them corrected an assumption of mine:a:\n- b\n - cis valid YAML, so the failing case is an unclosed quote instead.Drove the Storybook stories in light and dark and asserted on the rendered DOM: label and format row on one row, active language, validation icon tone, copy still a
<button>with itsaria-label, noion-iconanywhere. That caught two regressions I had introduced, alphabetically ordered format labels and a validation icon that vanished.E2E against the real editor, run on staging with a locally served bundle:
flag-tests,mv-options-tests,change-request-test,segment-testandversioning-tests. That is what turned up a strict mode violation in the override selector:getByRolematches the name as a substring, and an override row also holdsSegment Control Valueand read-onlyVariation Valueeditors.Not covered: the components in isolation. Jest runs
testEnvironment: 'node'with no React Testing Library, sovalidate.tsandcopyToClipboard.tsare the only unit testable parts, and thexmlbranch isit.todofor want ofDOMParser.Reviewing this
IcondropsclassNamefor most of its cases and hardcodes#FF9F43onwarning, so the colour class goes on a wrapper span here rather than changingIconand taking the blast radius. Deserves its own issue.aria-pressedtoggles, not aradiogroup. A radiogroup is more precise for a mutually exclusive set but wants roving tabindex and arrow key navigation. Tab plus Enter or Space is accessible today; say if you would rather have the full radiogroup.