Skip to content

refactor: make status indicator presentation reusable - #160

Open
a-effort wants to merge 5 commits into
mainfrom
extract-status-indicator-shell
Open

a-effort wants to merge 5 commits into
mainfrom
extract-status-indicator-shell

Conversation

@a-effort

@a-effort a-effort commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Moves the presentational part of ServerStatusIndicator into ui/status-indicator, so other surfaces can reuse the icon, label and popover without the server availability model. ServerStatusIndicator keeps the classification and the authorize branch, and composes the new component.

Behaviour is unchanged. ServerStatusIndicator.test.tsx is the same as on main and still passes.

PopoverContent also gets an aria-label. Radix gives it role="dialog" with no name, so it announced as an unnamed dialog. The trigger string will not work here, since it ends in "Show details" and would tell you to open something already open.

Sequence note: merge after #150, which rewrites the same return statement. Then, rebase this onto #150, then #153 onto this.

ServerStatusIndicator keeps the availability classification and the authorize
branch, and composes ui/status-indicator for the icon, label and popover, so
other surfaces can reuse the shape without the server status model.

Names the popover content as well. Radix gives PopoverContent role=dialog
without a name, and the trigger string ends in Show details, which reads as an
instruction to open what is already open.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The reusable component can still render an unnamed dialog when its optional content label is omitted.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Extracts a reusable status-indicator presentation component while retaining server-specific availability and authorization logic.

Changes:

  • Adds the reusable StatusIndicator component and tests.
  • Refactors ServerStatusIndicator to compose it.
  • Adds localized popover accessible names.
File Description
src/​components/​ui/​status-indicator.tsx Adds the reusable status UI shell.
src/​components/​ui/​status-indicator.test.tsx Tests rendering, popovers, and accessibility.
src/​components/​servers/​ServerStatusIndicator.tsx Uses the extracted component.
src/​i18n/​locales/​en-US/​mcpServer.json Adds the English dialog label.
src/​i18n/​locales/​es-ES/​mcpServer.json Adds the Spanish dialog label.
src/​i18n/​locales/​pt-BR/​mcpServer.json Adds the Portuguese dialog label.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/components/ui/status-indicator.tsx Outdated
contentAriaLabel is optional, so a caller passing children without it produced
the unnamed dialog this component exists to avoid. Falls back to fullLabel and
then label, both of which describe the status the popover explains.

Signed-off-by: Anna Effort <anna.effort@ibm.com>
@a-effort a-effort changed the title refactor: extract the presentational shell of the status indicator refactor: make status indicator presentation reusable Sep 25, 2026
@a-effort a-effort self-assigned this Sep 25, 2026
Extracting the shell split one pair of class strings across two files.

Also covers the trigger's accessible name, which comes from its content
rather than triggerAriaLabel.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

@marekdano marekdano left a comment

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.

Findings

🟠 Medium

1. isAbbreviated truthy check drops falsy fullLabel

File: src/components/ui/status-indicator.tsx:58

isAbbreviated uses Boolean(fullLabel && fullLabel !== label) — a truthy check instead of an "is defined" check. If fullLabel is ever "" while label is non-empty (e.g. a blank/missing translation), it's treated as "not abbreviated": the sr-only full-label span is skipped, and the aria-label fallback chain (contentAriaLabel ?? fullLabel ?? label) skips the falsy empty string too — silently losing the intended accessible name.

2. Status icon JSX duplicated across two files

File: src/components/servers/ServerStatusIndicator.tsx:66 (vs. status-indicator.tsx:63-67)

The status icon JSX is now duplicated (once in the authorize-button branch, once in StatusIndicator's content builder), where before it was one shared icon const. Any future icon/a11y tweak has to be made in both places, or the authorize button's icon silently desyncs from every other state. The exported STATUS_INDICATOR_TRIGGER_CLASS constant looks like the escape-hatch symptom of the abstraction boundary being drawn in the wrong place.

3. triggerAriaLabel optional despite being "required"

File: src/components/ui/status-indicator.tsx:16

triggerAriaLabel is typed optional even though its doc comment says it's "Required wherever children are given," with no runtime enforcement. A future caller with children but no triggerAriaLabel silently loses the guaranteed descriptive name.

🟡 Low

4. Dead size prop reinvents existing pattern

File: src/components/ui/status-indicator.tsx:19, 27-30

The new size?: "xs" | "sm" prop and its hand-rolled SIZE_CLASS map are dead code — no caller anywhere passes size — and reinvent a pattern the codebase already has via class-variance-authority (used for size variants in button.tsx and badge.tsx).

5. Docstring claims consumers that don't exist yet

File: src/components/ui/status-indicator.tsx:43

The docstring claims the catalog card and source picker are consumers sharing this shape, but this PR only migrates ServerStatusIndicator. CatalogResults.tsx and SourceSelection.tsx still hand-roll their own icon+label markup — the docstring is currently misleading until those two are migrated.

6. Unconditional formatMessage calls when non-interactive

File: src/components/servers/ServerStatusIndicator.tsx:110

triggerAriaLabel/contentAriaLabel are computed via intl.formatMessage unconditionally, even when interactive={false} (where StatusIndicator discards both and renders a plain span). The old code early-returned before this formatting.

7. Ambiguous aria-label fallback for popover content

File: src/components/ui/status-indicator.tsx:90

When a caller omits contentAriaLabel, the popover's aria-label falls back to the bare status word (e.g. "Available") with no entity identifier — multiple indicators for different entities would get identical accessible names. Better than pre-PR (no aria-label at all), but ambiguous.

@vishu-bh vishu-bh left a comment

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.

implementation looks clean and focused. A minor edge case to check

Comment thread src/components/ui/status-indicator.tsx Outdated
</>
);

if (!interactive || !children) {

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.

children is typed as ReactNode, so 0 is valid renderable content. This truthiness check treats 0 as absent and silently disables the popover. Could we use children == null instead and add a children={0} regression test?

Share the status icon with the authorize branch, drop the unused size
prop, and fall through a blank content label rather than leaving the
popover dialog unnamed.

Signed-off-by: Anna Effort <anna.effort@ibm.com>
@a-effort

a-effort commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing! Changes added in 82ac6a2 & f025d8c. Details:

@marekdano's review:

From LLM-assisted analysis of the feedback, the recommendation is to move forward with these changes:

Feedback item 2: implemented.

The status icon is now a StatusIndicatorIcon export used by both the component and the authorize branch, so the copies cannot drift. The authorize branch needs an onClick, a disabled state and the stacked two-label width trick, none of which StatusIndicator can render, so STATUS_INDICATOR_TRIGGER_CLASS stays as the shared piece.

Feedback item 4: implemented.

Removed the size prop and SIZE_CLASS. Nothing passes it, as you say. It was headroom for the catalog footer size mismatch queued behind #6972, but that can be added when needed as a follow up.

Feedback item 5: implemented.

Reworded the docstring to say what the component is for rather than naming consumers. The catalog card only becomes one in #153, and the source picker is not one on either branch.

Feedback item 1: partly implemented.

The aria-label chain used ??, so an empty contentAriaLabel won over the label and left the dialog unnamed, which is what the fallback was added to prevent. It is || now, with a test for the blank case. Left isAbbreviated as a truthy check: an empty fullLabel should not aria-hide the visible label and render an empty sr-only span, so truthiness is the right test there.

From LLM-assisted analysis of the feedback, the recommendation is to not move forward with these changes:

Feedback item 3: not implemented.

The type matches the behaviour, the comment did not. A button is named by its content, so the trigger has a name without triggerAriaLabel, which the "names the trigger from the visible label" test pins. The prop is an override for where the label alone does not say what the status is about, and the comment now says that.

Feedback item 7: not implemented.

The fallback is less specific than a caller-supplied name, agreed, but passing one is the caller's job and every caller does. The fallback is there so a caller who forgets gets an ambiguous dialog name rather than none.

Feedback item 6: not implemented.

Nothing is discarded today: ServersTable is the only call site and it does not pass interactive, so both labels are used. But that makes interactive a prop with no caller, like size, so it is removed in f025d8c.

@vishu-bh's review:
Thanks for checking this out! From LLM-assisted analysis of the feedback, the recommendation is to not move forward for the following reason:

!children does treat 0 as absent, but children == null isn't the right fix: {cond && <X/>} yields false, which must disable the popover rather than open an empty one, and == null would not catch that. A 0 as popover body doesn't arise in practice; a falsy conditional child does.

Lmk if I'm missing something, though pls! 🙌

No caller passes it, and it cannot stop a button nesting inside one: it
only lets a caller who already knows to avoid it.

Signed-off-by: Anna Effort <anna.effort@ibm.com>

@marekdano marekdano left a comment

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.

All findings have been addressed!
The PR looks good now.

LGTM 🚀

@vishu-bh vishu-bh left a comment

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.

PR looks god to merge but needs conflicts resolution

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants