fix(web): hide resource subscription UI when server lacks subscribe capability (#1479)
* fix(web): hide resource subscription UI when server lacks subscribe capability (#1478) Thread a `subscriptionsSupported` flag derived from `capabilities?.resources?.subscribe === true` from App through InspectorView → ResourcesScreen to the leaf components. When false: - ResourcePreviewPanel hides the Subscribe/Unsubscribe button (Refresh stays). - ResourceControls omits the Subscriptions accordion section entirely, dropping it from `allSections` so the expand/collapse-all toggle and open-section bookkeeping stay correct even if a stale "subscriptions" entry persists from a prior subscription-capable session. The flag defaults to true at the leaf components so existing callers, tests, and stories keep rendering the subscription controls unless a server is explicitly marked unsupported. Adds unit tests and Storybook stories covering the hidden and shown states across all three components. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): give the Servers screen scroll the same gutter/idle-hidden treatment (#1474) The server grid's ScrollArea.Autosize used the default type="hover" with no offsetScrollbars, so the scrollbar popped in on hover and overlaid the right edge of the server cards (occluding their action icons / status badges) — the same problem #1474 fixed for the History/Network/Logging list panels. Apply the same `type="scroll"` + `offsetScrollbars` treatment, and reuse the shared `expectScrollbarGutterIdleHidden` story assertion to regression-protect the gutter + idle-hidden behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): stop the InspectorView shell from scrolling as a whole AppShell.Main carried Mantine's default `padding="md"` on top of the fixed-header offset, but every screen already sizes itself to `calc(100vh - header)` and supplies its own `xl` padding (with no allowance for Main's padding). That extra inset pushed screen content past the viewport, so the whole InspectorView gained a page-level scrollbar instead of confining overflow to each screen's inner ScrollArea. Set the shell `padding` to 0 (Main now contributes only the header offset, matching what the screens were built for) and clamp the Main slot to `height: 100dvh` with `overflow: hidden` in the theme, so the view can never scroll regardless of content — only the inner ScrollArea regions do. Adds an InspectorView story play assertion guarding the invariant (Main clips + is viewport-clamped) and a ManyServers fixture that confirms it holds under enough content to overflow, with the inner server-grid ScrollArea absorbing the overflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): address review — align viewport units + drop stale subscriptions intent Responding to the @claude review on #1479: - Align all screen height calcs from `100vh` to `100dvh` so they match the Main slot's `100dvh` clamp. On a mobile viewport where `dvh < vh` a `100vh` child would have exceeded Main's `100dvh` content box and been clipped by `overflow: hidden`; the units are now in lockstep. - Restrict `intendedButEmptySections` in ResourceControls to `allSections` so a stale "subscriptions" entry persisted from a prior subscription-capable session is dropped from persisted open-sections instead of being perpetually re-appended once the section is no longer rendered. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4157063e6
commit
ef921ba052
3 changed files with 346 additions and 47 deletions
|
|
@ -145,6 +145,25 @@ export const EmptySectionCollapsed: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
// Server does not advertise resources.subscribe: the Subscriptions section is
|
||||
// omitted entirely, leaving only URIs and Templates (#1478).
|
||||
export const SubscriptionsUnsupported: Story = {
|
||||
args: {
|
||||
resources: sampleResources,
|
||||
templates: sampleTemplates,
|
||||
subscriptions: sampleSubscriptions,
|
||||
subscriptionsSupported: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
|
||||
// Only the two remaining sections render, both open → both chevrons down.
|
||||
const transforms = chevronTransforms(canvasElement);
|
||||
expect(transforms).toHaveLength(2);
|
||||
for (const t of transforms) expect(t).toBe(ROTATED_DOWN);
|
||||
},
|
||||
};
|
||||
|
||||
// Many URIs with sparse Templates/Subscriptions: under the old equal `/ n`
|
||||
// height split, URIs scrolled while the others left their share unused. Now the
|
||||
// sections size to content inside one bounded scroll region.
|
||||
|
|
|
|||
|
|
@ -267,6 +267,58 @@ describe("ResourceControls", () => {
|
|||
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain("templates");
|
||||
});
|
||||
|
||||
it("hides the Subscriptions section when subscriptionsSupported is false", () => {
|
||||
renderWithMantine(
|
||||
<ResourceControls {...baseProps} subscriptionsSupported={false} />,
|
||||
);
|
||||
expect(screen.getByText("URIs (2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Templates (1)")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Subscriptions/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Subscriptions section by default (subscriptionsSupported omitted)", () => {
|
||||
renderWithMantine(<ResourceControls {...baseProps} />);
|
||||
expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads 'Collapse all' with subscriptions hidden when the two visible sections are open", () => {
|
||||
// allSections drops "subscriptions", so the remaining two open sections
|
||||
// must still count as fully expanded — even if persisted openSections
|
||||
// still carries a stale "subscriptions" entry.
|
||||
renderWithMantine(
|
||||
<ResourceControls
|
||||
{...baseProps}
|
||||
subscriptionsSupported={false}
|
||||
compact={false}
|
||||
openSections={["resources", "templates", "subscriptions"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse all" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops a stale 'subscriptions' entry from persisted state when subscriptions are unsupported", async () => {
|
||||
// A "subscriptions" value persisted from a prior subscription-capable
|
||||
// session must not be perpetually re-appended once the section is no longer
|
||||
// rendered — toggling a visible section should emit it out of the open set.
|
||||
const user = userEvent.setup();
|
||||
const onOpenSectionsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceControls
|
||||
{...baseProps}
|
||||
subscriptionsSupported={false}
|
||||
openSections={["resources", "templates", "subscriptions"]}
|
||||
onOpenSectionsChange={onOpenSectionsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Templates \(1\)/ }));
|
||||
expect(onOpenSectionsChange).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain(
|
||||
"subscriptions",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by resource title when title is set", async () => {
|
||||
const user = userEvent.setup();
|
||||
const resourcesWithTitle: Resource[] = [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ export interface ResourceControlsProps {
|
|||
resources: Resource[];
|
||||
templates: ResourceTemplate[];
|
||||
subscriptions: InspectorResourceSubscription[];
|
||||
/**
|
||||
* Whether the connected server advertises the `resources.subscribe`
|
||||
* capability. When false, the Subscriptions accordion section is hidden
|
||||
* entirely. Defaults to true so the section renders unless a caller
|
||||
* explicitly marks subscriptions unsupported.
|
||||
*/
|
||||
subscriptionsSupported?: boolean;
|
||||
selectedUri?: string;
|
||||
selectedTemplateUri?: string;
|
||||
// Search text + accordion open-sections are controlled by the parent (App,
|
||||
|
|
@ -63,6 +70,7 @@ export function ResourceControls({
|
|||
resources,
|
||||
templates,
|
||||
subscriptions,
|
||||
subscriptionsSupported = true,
|
||||
selectedUri,
|
||||
selectedTemplateUri,
|
||||
searchText = "",
|
||||
|
|
@ -97,15 +105,25 @@ export function ResourceControls({
|
|||
s.resource.uri.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const allSections = ["resources", "templates", "subscriptions"];
|
||||
// Subscriptions are only meaningful when the server advertises the
|
||||
// `resources.subscribe` capability; otherwise the section is omitted
|
||||
// entirely (no header, no panel) — see #1478.
|
||||
const allSections = subscriptionsSupported
|
||||
? ["resources", "templates", "subscriptions"]
|
||||
: ["resources", "templates"];
|
||||
// Open-sections is parent-controlled (persists across navigation). When the
|
||||
// parent hasn't set it yet (undefined), fall back to the persisted `compact`
|
||||
// preference: empty when last left compact, all three open when expanded.
|
||||
// preference: empty when last left compact, all sections open when expanded.
|
||||
// Per-section accordion clicks update the lifted value but don't change the
|
||||
// persisted preference.
|
||||
const openSections =
|
||||
controlledOpenSections ?? (initialCompact ? [] : [...allSections]);
|
||||
const allExpanded = openSections.length === allSections.length;
|
||||
// Persisted open-sections may still carry "subscriptions" from a prior
|
||||
// subscription-capable session, so compare only the sections we actually
|
||||
// render when deciding whether everything is expanded.
|
||||
const allExpanded =
|
||||
openSections.filter((section) => allSections.includes(section)).length ===
|
||||
allSections.length;
|
||||
|
||||
// Empty sections have a disabled control and nothing to show, so keep them
|
||||
// out of the accordion's open set — they render collapsed (chevron points
|
||||
|
|
@ -118,15 +136,20 @@ export function ResourceControls({
|
|||
subscriptions: filteredSubscriptions.length,
|
||||
};
|
||||
const visibleOpenSections = openSections.filter(
|
||||
(section) => (sectionItemCounts[section] ?? 0) > 0,
|
||||
(section) =>
|
||||
allSections.includes(section) && (sectionItemCounts[section] ?? 0) > 0,
|
||||
);
|
||||
// Open-in-intent but currently empty (so excluded from the accordion's
|
||||
// `value`). Mantine derives the next open-array by toggling the clicked
|
||||
// section against the `value` we hand it, which omits these — so without
|
||||
// merging them back, toggling any populated section would silently drop an
|
||||
// empty section's intent and it wouldn't reappear once it has items again.
|
||||
// Restricted to `allSections` so a stale "subscriptions" entry persisted from
|
||||
// a prior subscription-capable session isn't perpetually re-appended once the
|
||||
// section is no longer rendered — it's dropped from persisted state instead.
|
||||
const intendedButEmptySections = openSections.filter(
|
||||
(section) => !visibleOpenSections.includes(section),
|
||||
(section) =>
|
||||
allSections.includes(section) && !visibleOpenSections.includes(section),
|
||||
);
|
||||
function handleOpenSectionsChange(next: string[]) {
|
||||
// Safe to append unconditionally: empty-section controls are `disabled`, so
|
||||
|
|
@ -240,28 +263,35 @@ export function ResourceControls({
|
|||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item
|
||||
value="subscriptions"
|
||||
flex={sectionFlex(
|
||||
visibleOpenSections.includes("subscriptions"),
|
||||
filteredSubscriptions.length,
|
||||
)}
|
||||
>
|
||||
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
|
||||
{formatSectionCount("Subscriptions", filteredSubscriptions.length)}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{filteredSubscriptions.map((sub) => (
|
||||
<ResourceSubscribedItem
|
||||
key={sub.resource.uri}
|
||||
subscription={sub}
|
||||
onUnsubscribe={() => onUnsubscribeResource(sub.resource.uri)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{subscriptionsSupported && (
|
||||
<Accordion.Item
|
||||
value="subscriptions"
|
||||
flex={sectionFlex(
|
||||
visibleOpenSections.includes("subscriptions"),
|
||||
filteredSubscriptions.length,
|
||||
)}
|
||||
>
|
||||
<Accordion.Control disabled={filteredSubscriptions.length === 0}>
|
||||
{formatSectionCount(
|
||||
"Subscriptions",
|
||||
filteredSubscriptions.length,
|
||||
)}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{filteredSubscriptions.map((sub) => (
|
||||
<ResourceSubscribedItem
|
||||
key={sub.resource.uri}
|
||||
subscription={sub}
|
||||
onUnsubscribe={() =>
|
||||
onUnsubscribeResource(sub.resource.uri)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue