Skip to content

[OPIK-7278] [FE] refactor: migrate LoadableSelectBox to the new design system (v2)#7551

Merged
awkoy merged 7 commits into
mainfrom
awkoy/opik-7278-loadable-select-box-ds
Jul 23, 2026
Merged

[OPIK-7278] [FE] refactor: migrate LoadableSelectBox to the new design system (v2)#7551
awkoy merged 7 commits into
mainfrom
awkoy/opik-7278-loadable-select-box-ds

Conversation

@awkoy

@awkoy awkoy commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Details

Migrates the searchable single/multi-select popover (LoadableSelectBox) to the new design system on v2 pages, so it reads consistently with the migrated DS Select and the Project/Workspace selectors. This is a visual-only migration — no behavior changes, and v1 stays byte-identical.

  • LoadableSelectBox is shared by v1 and v2, so instead of an in-place restyle we fork a v2-owned copy at src/v2/components/LoadableSelectBox/ and repoint only the 12 v2 consumers.
  • Visual: chevron DS token; option rows h-10/px-4h-8/px-3; selected single-select row filled (bg-primary-100/text-primary); group headers text-light-slate; in-popover search field compact dimension="sm"; create/action footer normalized to <ListAction size="sm"> (AddToDatasetDialog raw <div>ListAction, also fixes keyboard a11y).
  • Optional per-option icon on the fork's local option type (opt-in, unused by default).
  • The original src/shared/LoadableSelectBox and all v1 usages are untouched.

Change checklist

  • User facing
  • Documentation update

Issues

  • OPIK-7278

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 4.8
  • Scope: authored the fork, the visual restyle, the 12 consumer repoints, and the unit test
  • Human verification: author review + local checks (tsc / eslint / vitest) and in-browser drive of the dataset select

Testing

Commands (in apps/opik-frontend):

  • npx tsc --noEmit — clean
  • npx eslint <changed files> — clean
  • npx vitest run src/v2/components/LoadableSelectBox/LoadableSelectBox.test.tsx — 4/4 pass (collapsed-trigger render + title logic)

Scenarios validated:

  • All 12 v2 consumers compile against the fork; v1 + src/shared/LoadableSelectBox unchanged (verified via git diff — zero changes under src/v1/** and src/shared/LoadableSelectBox/**).
  • Live drive of the dataset select (Add-to-dataset): trigger states, hover, focus, selected-row highlight, compact search field, empty state, and the "Add dataset" action row all render correctly.

Environment: local Vite dev server proxied to a local backend (process mode), Chrome.

Not run: full v1 regression suite — out of scope by design (v1 is untouched).

Video evidence: none (visual diff is small and covered by the in-browser drive above).

Documentation

No documentation changes.

🤖 Generated with Claude Code

…n system (v2)

Fork the searchable single/multi-select popover into a v2-owned component
(src/v2/components/LoadableSelectBox) and align it visually with the migrated
DS Select and the Project/Workspace selectors, without touching v1.

Visual changes (fork):
- Trigger chevron -> DS token (text-foreground/opacity-50, size-3.5)
- Option rows h-10/px-4 -> h-8/px-3; selected single-select row filled
  (bg-primary-100/text-primary); group headers text-light-slate
- In-popover search field -> compact dimension="sm" (matches Project/Workspace)
- Optional per-option `icon` (local option type; opt-in, unused by default)

Consumers:
- Repoint 12 v2 usages to the fork; normalize the create/action footer to
  <ListAction size="sm"> (AddToDatasetDialog raw div -> ListAction, fixes a11y)

Behavior is unchanged (client-side search preserved). v1 and the shared
LoadableSelectBox are untouched. tsc/eslint/vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 42.42s
🌐 eslint — frontend Lint + autofix JS/TS 11.74s
⚓ helm-docs Regenerate Helm chart README 7.36s
🤖 mypy — optimizer Static type check 5.58s
☕ spotless — java backend Format Java code 5.19s
🐍 mypy — python sdk Static type check 1.13s
⚙️ actionlint — github workflows Lint GitHub Actions workflows 0.88s
🧹 vulture — optimizer Find dead code 0.82s
📊 xenon — optimizer Fail on complexity thresholds 0.58s
📊 lizard — optimizer Cyclomatic-complexity gate 0.51s
📊 radon cc — optimizer Cyclomatic-complexity gate 0.43s
🤖 pyupgrade — optimizer Modernize Python syntax 0.11s
📊 radon raw — optimizer Raw size metrics gate 0.11s
🔤 codespell — optimizer Fix common misspellings 0.09s
🤖 check for case conflicts — optimizer Block case-only name clashes 0.06s
🤖 check for added large files — optimizer Block large files (>1MB) 0.04s
🤖 check for merge conflicts — optimizer Block merge-conflict markers 0.04s
🔐 detect private key — optimizer Block committed private keys 0.03s
🤖 fix end of files — optimizer Ensure files end in a newline 0.03s
🤖 trim trailing whitespace — optimizer Strip trailing whitespace 0.03s
🐍 trim trailing whitespace — python sdk Strip trailing whitespace 0.03s
🐍 fix end of files — python sdk Ensure files end in a newline 0.02s
🤖 ruff-format — optimizer Format Python code (ruff) 0.01s
🐍 ruff-format — python sdk Format Python code (ruff) 0.01s
🤖 ruff — optimizer Lint + autofix Python (ruff) 0.01s
🐍 ruff — python sdk Lint + autofix Python (ruff) 0.01s
Total (26 ran) 77.27s
⏭️ 15 skipped (no matching files changed)
Hook Description Result
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️

@awkoy awkoy added the test-environment Deploy Opik adhoc environment label Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.0-6074 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch awkoy/opik-7278-loadable-select-box-ds
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

Comment thread apps/opik-frontend/src/v2/components/LoadableSelectBox/LoadableSelectBox.tsx Outdated
Comment thread apps/opik-frontend/src/v2/components/LoadableSelectBox/NoOptions.tsx Outdated
@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

…ct/Workspace selectors

- Render the in-popover search inline (px-0.5) instead of absolutely
  positioned, so it left-aligns with the option rows (removes the pt-11
  spacer hack).
- Switch dividers from bg-border to the lighter bg-muted with -mx-px
  (search separator, group separator, select-all separator, load-more
  border, and the actionPanel separators in the 3 create-selects) to match
  the reference selectors' divider color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@awkoy awkoy added test-environment Deploy Opik adhoc environment and removed test-environment Deploy Opik adhoc environment labels Jul 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.0-6074 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch awkoy/opik-7278-loadable-select-box-ds
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The test environment for this PR (pr-7551) has been cleaned up to free cluster resources. PVCs are preserved — re-deploy to restore the environment.

@CometActions CometActions removed the test-environment Deploy Opik adhoc environment label Jul 22, 2026
@awkoy awkoy added the test-environment Deploy Opik adhoc environment label Jul 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.0-6074 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch awkoy/opik-7278-loadable-select-box-ds
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

…e-select rows

Design feedback: the selected single-select option is already conveyed by
the filled blue row (bg-primary-100/text-primary), so the leading Check icon
(and the min-w-4 spacer reserved for it on unselected rows) is redundant.
Remove both so all single-select labels sit flush at the 12px row padding.
Multiselect keeps its checkbox. Verified e2e (dataset selector).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +60 to +65
interface SingleSelectProps extends BaseLoadableSelectBoxProps {
value?: string;
onChange: (value: string) => void;
multiselect?: false;
renderTitle?: (option: DropdownOption<string>) => ReactElement;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderTitle typing drops option icon

renderTitle is still typed as (option: DropdownOption<string>), so v2 consumers can’t read option.icon even though LoadableSelectBoxOption is what gets passed in — should we widen the callback to LoadableSelectBoxOption and drop the cast at the call site? That would avoid forcing icon duplication or local type assertions at the boundary.

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/v2/components/LoadableSelectBox/LoadableSelectBox.tsx around
lines 60-65 where `LoadableSelectBoxOption` and the single-select `renderTitle` prop are
typed, fix the type inconsistency that currently prevents `renderTitle` callbacks from
safely reading `option.icon`. Refactor by changing the `renderTitle` prop type to accept
the actual option shape used by the component (e.g., `LoadableSelectBoxOption` or an
extended type that includes the optional `icon`) and then update the internal invocation
to pass `selectedOptions` without casting to the older shared contract. After the
change, remove any TypeScript cast/override at the callback boundary so consumer
callbacks have the correct type and no icon duplication/type assertions are required.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 981c2d6 addressed this comment by widening both renderTitle callback types to LoadableSelectBoxOption, allowing consumers to access option.icon. The internal invocation still uses casts, so that part of the suggestion was not fully implemented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 981c2d6 — widened renderTitle to LoadableSelectBoxOption so consumers can read icon without casting.

Comment on lines +67 to +72
interface MultiSelectProps extends BaseLoadableSelectBoxProps {
value?: string[];
onChange: (value: string[]) => void;
multiselect: true;
renderTitle?: (option: DropdownOption<string>[]) => ReactElement;
showSelectAll?: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi renderTitle typing drops option icon

renderTitle is typed as DropdownOption<string>[], so v2 callers lose LoadableSelectBoxOption.icon at the callback boundary and have to cast or duplicate data to render selected titles — should we type it as LoadableSelectBoxOption[]?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/v2/components/LoadableSelectBox/LoadableSelectBox.tsx around
lines 67-72, the multi-select `renderTitle` prop is typed as `(option:
DropdownOption<string>[])` even though this component’s options are
`LoadableSelectBoxOption` (which includes `icon?: ReactNode`). Refactor the prop/type
definitions so the callback receives `LoadableSelectBoxOption[]` (or whatever exact
option type the component passes to `renderTitle`), and then ensure the internal call
sites use that same type. This will align the types at the callback boundary so v2
consumers can read `option.icon` without type assertions or duplicated option mapping.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 981c2d6 addressed this comment by typing multi-select renderTitle with LoadableSelectBoxOption[], preserving access to icon. Internal callback invocation casts were updated to use the same option type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 981c2d6 — widened renderTitle to LoadableSelectBoxOption[] (same as the single-select fix).

Comment on lines +349 to +353
className={cn(
"group flex cursor-pointer items-center gap-2 rounded-md px-3",
option.description ? "min-h-12 py-2" : "h-8",
!multiselect && selected
? "bg-primary-100 text-primary hover:bg-secondary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-select lacks non-color selected cue

In the v2 fork, single-select rows only signal selection through theme colors and the Checkbox is gated behind multiselect, so the selected option stays ambiguous in the DOM for screen readers, high-contrast mode, and keyboard workflows — should we add an explicit selected marker or aria-selected on the row?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-frontend/src/v2/components/LoadableSelectBox/LoadableSelectBox.tsx around
lines 349-353 (the single-select option row rendering logic inside the map/render for
options), the selected state is expressed only via conditional Tailwind classes and
there is no explicit selected marker element for single-select, plus the row is a plain
<div> without role/aria-selected. Update this option row to include proper listbox
semantics (set role="option" and aria-selected={selected}) and add a non-color-dependent
selected indicator for single-select (e.g., render a check icon or a visually-hidden
“Selected” span) while keeping the Checkbox gated behind multiselect. Ensure the
selected indicator updates in the same branch that currently toggles the bg/text classes
so DOM and visuals agree for screen readers and high-contrast/keyboard users.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 981c2d6 addressed this comment by adding listbox semantics and aria-selected={selected} to each option row, making single-select state available to assistive technologies without relying solely on color.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 981c2d6 — added role="listbox"/role="option" + aria-selected (and aria-multiselectable for multiselect) so selection is exposed to assistive tech. No visual change — the check icon stays removed per design.

@CometActions

Copy link
Copy Markdown
Collaborator

🌙 Nightly cleanup: The test environment for this PR (pr-7551) has been cleaned up to free cluster resources. PVCs are preserved — re-deploy to restore the environment.

@CometActions CometActions removed the test-environment Deploy Opik adhoc environment label Jul 23, 2026
awkoy and others added 3 commits July 23, 2026 11:03
Design feedback: option rows in the base Select (e.g. the Filters "Column"
picker) used 8px horizontal padding, inconsistent with the 12px rows of the
migrated dropdowns. Bump the base SelectItem padding px-2 -> px-3 so all
select list items share 12px x-padding. Single-line rows are already 32px
tall (py-1.5 + text-sm), so no height change is needed.

Note: this is the shared base Select primitive, so it affects every Select
across v1 and v2, not only v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the Baz "extra list scan on every render" finding: filteredSelectedCount
re-filtered filteredOptions on every render (hot path when the popover is open /
typing). Wrap it in useMemo keyed on multiselect/filteredOptions/selectedValuesSet
so it recomputes only when its inputs change. Pure perf, no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In-scope fixes for code introduced by this v2 fork:
- a11y: after dropping the check icon, single-select selection was conveyed by
  color only. Add role="listbox"/role="option" + aria-selected on the rows
  (and aria-multiselectable for the multiselect case) so the selected option is
  exposed to screen readers / high-contrast / keyboard — no visual change.
- Widen renderTitle callbacks to LoadableSelectBoxOption so v2 consumers can
  read the new optional `icon` field without casting.
- Rename NoOptions' props type SelectBoxProps -> NoOptionsProps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@awkoy awkoy added the test-environment Deploy Opik adhoc environment label Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Test environment deployment process has started

Phase 1: Deploying base version 2.2.0-6074 (from main branch) if environment doesn't exist
Phase 2: Building new images from PR branch awkoy/opik-7278-loadable-select-box-ds
Phase 3: Will deploy newly built version after build completes

You can monitor the progress here.

Comment on lines +335 to +344
<div
className="max-h-[40vh] overflow-y-auto overflow-x-hidden"
role="listbox"
aria-multiselectable={multiselect || undefined}
>
{isLoading && (
<div className="flex items-center justify-center">
<Spinner />
</div>
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The role="listbox" <div> is non-focusable and contains the loading spinner and NoOptions subtree, so screen readers see non-option descendants inside the listbox and keyboard users have no way to navigate or select — should we restrict the listbox subtree to only role="option" rows (moving loading/empty states outside or marking them aria-hidden), and add tabIndex/onKeyDown/aria-activedescendant support for ArrowUp/ArrowDown/Enter/Space?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code in
apps/opik-frontend/src/v2/components/LoadableSelectBox/LoadableSelectBox.tsx around
lines 335-344. Two related ARIA issues to fix together: 1. **Listbox content**: The `div
role="listbox"` conditionally renders a loading spinner and `NoOptions`/`Button` subtree
as children, which violates the ARIA listbox model (only `role="option"` descendants are
allowed). Move the loading container and empty-state rendering (`emptyState ? emptyState
: <NoOptions .../>`) outside the `role="listbox"` div as siblings. If they must stay
inside, wrap them with `aria-hidden={true}` and `role="presentation"`, and ensure the
interactive "Load more items" Button is not inside the listbox subtree. Verify that the
only elements inside `role="listbox"` are option rows with `role="option"` and correct
`aria-selected`. 2. **Keyboard navigation**: The `role="listbox"` container is a
non-focusable `<div>` and `role="option"` rows only handle `onClick`, leaving keyboard
users unable to focus or navigate the list. Refactor to make the listbox focusable (add
`tabIndex` and manage active focus state) and implement an `onKeyDown` handler
supporting ArrowUp/ArrowDown (and Home/End) to move the active option, and Enter/Space
to select it. Update option rows (around where `role="option"` and `aria-selected` are
set, and the selection logic in the `onClick` handler near ~367) to participate in a
roving tabindex or `aria-activedescendant` model.

@CometActions

Copy link
Copy Markdown
Collaborator

Test environment is now available!

To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml)

Access Information

The deployment has completed successfully and the version has been verified.

The "Add {entity}" create footers rendered ListAction with no variant, so they
fell back to the blue `primary` style. Match the Project selector's footer by
passing variant="default" (neutral text-foreground) on the create actions in
AddToDatasetDialog, FeedbackDefinitionsSelectBox, and PromptsSelectBox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@aadereiko aadereiko left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, LGTM!

@awkoy
awkoy merged commit fa1a706 into main Jul 23, 2026
38 checks passed
@awkoy
awkoy deleted the awkoy/opik-7278-loadable-select-box-ds branch July 23, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Frontend 🔴 size/XL test-environment Deploy Opik adhoc environment typescript *.ts *.tsx

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants