Extract ResourceControls, refactor ResourcesScreen layout, and add scrollable accordion panels

- Extract ResourceControls group component from ResourcesScreen:
  title, search, list-changed indicator, and three-section accordion
  (URIs, Templates, Subscriptions) with per-panel ScrollArea.Autosize
- Refactor ResourcesScreen to sidebar+panel Flex layout matching
  HistoryScreen/LoggingScreen pattern (ScreenLayout, Sidebar, SidebarCard)
- Each accordion panel dynamically computes max height based on how many
  sections are open, dividing available space evenly
- Accordion uses controlled state (value/onChange) to track open sections
- Sidebar card sizes to content naturally (compact when collapsed,
  grows with open sections, capped by scroll areas)
- Right panel wrapped in ScrollArea.Autosize for long content
- Add AllSections story with 20 URIs, 7 templates, 8 subscriptions
- Add more templates and subscriptions to ConnectedView ResourcesActive
- Add ResourceControls stories (Default, WithSearch, Empty)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-31 14:38:02 -04:00
commit eca6cf7665
5 changed files with 627 additions and 162 deletions

View file

@ -0,0 +1,72 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ResourceControls } from "./ResourceControls";
const meta: Meta<typeof ResourceControls> = {
title: "Groups/ResourceControls",
component: ResourceControls,
args: {
searchText: "",
onSearchChange: fn(),
onRefreshList: fn(),
onSelectUri: fn(),
onSelectTemplate: fn(),
onUnsubscribeResource: fn(),
listChanged: false,
},
};
export default meta;
type Story = StoryObj<typeof ResourceControls>;
export const Default: Story = {
args: {
resources: [
{
name: "config.json",
uri: "file:///config.json",
annotations: { audience: "developer", priority: 0.8 },
selected: false,
},
{
name: "README.md",
uri: "file:///README.md",
selected: false,
},
{
name: "schema.sql",
uri: "file:///schema.sql",
selected: false,
},
],
templates: [
{
name: "User Profile",
uriTemplate: "file:///users/{userId}/profile",
selected: false,
},
],
subscriptions: [
{
name: "config.json",
uri: "file:///config.json",
lastUpdated: "2026-03-17T10:30:00Z",
},
],
},
};
export const WithSearch: Story = {
args: {
...Default.args,
searchText: "config",
},
};
export const Empty: Story = {
args: {
resources: [],
templates: [],
subscriptions: [],
},
};

View file

@ -0,0 +1,144 @@
import { useState } from "react";
import { Accordion, ScrollArea, Stack, TextInput, Title } from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ResourceListItem } from "../ResourceListItem/ResourceListItem";
import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem";
import type {
ResourceItem,
TemplateListItem,
SubscriptionItem,
} from "../../screens/ResourcesScreen/ResourcesScreen";
export interface ResourceControlsProps {
resources: ResourceItem[];
templates: TemplateListItem[];
subscriptions: SubscriptionItem[];
listChanged: boolean;
searchText: string;
onSearchChange: (text: string) => void;
onRefreshList: () => void;
onSelectUri: (uri: string) => void;
onSelectTemplate: (uriTemplate: string) => void;
onUnsubscribeResource: (uri: string) => void;
}
// Available height for each accordion panel's scroll area.
// Subtracts: AppShell extra padding (md*2), screen padding (xl*2, already in calc),
// card padding (2*lg=40), title+search+gaps (~84), 3 accordion controls (~144),
// and per-panel accordion padding (~20px each).
function panelMaxHeight(openCount: number): string {
const n = Math.max(openCount, 1);
const fixedChrome = 300;
const perPanelChrome = 20;
return `calc((100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - ${fixedChrome + perPanelChrome * n}px) / ${n})`;
}
function formatSectionCount(label: string, count: number): string {
return `${label} (${count})`;
}
function templateDisplayName(item: TemplateListItem): string {
return item.title ?? item.name;
}
export function ResourceControls({
resources,
templates,
subscriptions,
listChanged,
searchText,
onSearchChange,
onRefreshList,
onSelectUri,
onSelectTemplate,
onUnsubscribeResource,
}: ResourceControlsProps) {
const filteredResources = resources.filter((r) =>
r.name.toLowerCase().includes(searchText.toLowerCase()),
);
const defaultOpen = [
...(filteredResources.length > 0 ? ["resources"] : []),
...(templates.length > 0 ? ["templates"] : []),
...(subscriptions.length > 0 ? ["subscriptions"] : []),
];
const [openSections, setOpenSections] = useState<string[]>(defaultOpen);
const maxHeight = panelMaxHeight(openSections.length);
return (
<Stack gap="sm">
<Title order={4}>Resources</Title>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
<TextInput
placeholder="Search..."
value={searchText}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<Accordion multiple value={openSections} onChange={setOpenSections}>
<Accordion.Item value="resources">
<Accordion.Control disabled={filteredResources.length === 0}>
{formatSectionCount("URIs", filteredResources.length)}
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize mah={maxHeight}>
<Stack gap="xs">
{filteredResources.map((resource) => (
<ResourceListItem
key={resource.uri}
name={resource.name}
uri={resource.uri}
annotations={resource.annotations}
selected={resource.selected}
onClick={() => onSelectUri(resource.uri)}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="templates">
<Accordion.Control disabled={templates.length === 0}>
{formatSectionCount("Templates", templates.length)}
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize mah={maxHeight}>
<Stack gap="xs">
{templates.map((template) => (
<ResourceListItem
key={template.uriTemplate}
name={templateDisplayName(template)}
uri={template.uriTemplate}
selected={template.selected}
onClick={() => onSelectTemplate(template.uriTemplate)}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="subscriptions">
<Accordion.Control disabled={subscriptions.length === 0}>
{formatSectionCount("Subscriptions", subscriptions.length)}
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize mah={maxHeight}>
<Stack gap="xs">
{subscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.name}
name={sub.name}
lastUpdated={sub.lastUpdated}
onUnsubscribe={() => onUnsubscribeResource(sub.uri)}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}

View file

@ -48,6 +48,101 @@ const sampleResources: ResourceItem[] = [
annotations: { priority: 0.5 },
selected: false,
},
{
name: "package.json",
uri: "file:///package.json",
annotations: { audience: "developer" },
selected: false,
},
{
name: "tsconfig.json",
uri: "file:///tsconfig.json",
annotations: { audience: "developer", priority: 0.3 },
selected: false,
},
{
name: ".env.example",
uri: "file:///.env.example",
selected: false,
},
{
name: "docker-compose.yml",
uri: "file:///docker-compose.yml",
annotations: { priority: 0.6 },
selected: false,
},
{
name: "migrations/001_init.sql",
uri: "file:///migrations/001_init.sql",
annotations: { audience: "developer", priority: 0.4 },
selected: false,
},
{
name: "migrations/002_add_users.sql",
uri: "file:///migrations/002_add_users.sql",
annotations: { audience: "developer", priority: 0.4 },
selected: false,
},
{
name: "seeds/users.json",
uri: "file:///seeds/users.json",
selected: false,
},
{
name: "seeds/products.json",
uri: "file:///seeds/products.json",
selected: false,
},
{
name: "certs/server.pem",
uri: "file:///certs/server.pem",
annotations: { priority: 0.9 },
selected: false,
},
{
name: "logs/access.log",
uri: "file:///logs/access.log",
annotations: { audience: "application", priority: 0.2 },
selected: false,
},
{
name: "logs/error.log",
uri: "file:///logs/error.log",
annotations: { audience: "application", priority: 0.7 },
selected: false,
},
{
name: "api-spec.yaml",
uri: "file:///api-spec.yaml",
annotations: { audience: "developer" },
selected: false,
},
{
name: "CHANGELOG.md",
uri: "file:///CHANGELOG.md",
selected: false,
},
{
name: "LICENSE",
uri: "file:///LICENSE",
selected: false,
},
{
name: ".gitignore",
uri: "file:///.gitignore",
selected: false,
},
{
name: "Makefile",
uri: "file:///Makefile",
annotations: { audience: "developer", priority: 0.3 },
selected: false,
},
{
name: "fixtures/test-data.json",
uri: "file:///fixtures/test-data.json",
selected: false,
},
];
const sampleTemplates: TemplateListItem[] = [
@ -62,6 +157,75 @@ const sampleTemplates: TemplateListItem[] = [
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
selected: false,
},
{
name: "Log File",
title: "Application Log",
uriTemplate: "file:///logs/{service}/{date}.log",
selected: false,
},
{
name: "Migration",
uriTemplate: "file:///migrations/{version}_{name}.sql",
selected: false,
},
{
name: "Config by Environment",
title: "Environment Config",
uriTemplate: "file:///config/{environment}.json",
selected: false,
},
{
name: "API Endpoint",
uriTemplate: "https://api.example.com/{version}/{resource}",
selected: false,
},
{
name: "Report",
title: "Generated Report",
uriTemplate: "reports://{reportType}/{year}/{month}",
selected: false,
},
];
const sampleSubscriptions = [
{
name: "config.json",
uri: "file:///config.json",
lastUpdated: "2026-03-17T10:30:00Z",
},
{
name: "schema.sql",
uri: "file:///schema.sql",
lastUpdated: "2026-03-17T10:28:00Z",
},
{
name: "docker-compose.yml",
uri: "file:///docker-compose.yml",
lastUpdated: "2026-03-17T09:45:00Z",
},
{
name: "logs/error.log",
uri: "file:///logs/error.log",
lastUpdated: "2026-03-17T10:31:12Z",
},
{
name: "certs/server.pem",
uri: "file:///certs/server.pem",
},
{
name: "api-spec.yaml",
uri: "file:///api-spec.yaml",
lastUpdated: "2026-03-17T08:15:00Z",
},
{
name: "package.json",
uri: "file:///package.json",
lastUpdated: "2026-03-17T10:22:00Z",
},
{
name: "seeds/users.json",
uri: "file:///seeds/users.json",
},
];
const selectedResourceData: SelectedResource = {
@ -99,13 +263,7 @@ export const ResourceSelected: Story = {
r.uri === "file:///config.json" ? { ...r, selected: true } : r,
),
selectedResource: selectedResourceData,
subscriptions: [
{
name: "config.json",
uri: "file:///config.json",
lastUpdated: "2026-03-17T10:30:00Z",
},
],
subscriptions: sampleSubscriptions,
},
};
@ -152,6 +310,31 @@ export const TemplateWithResource: Story = {
},
};
export const AllSections: Story = {
args: {
resources: sampleResources,
templates: sampleTemplates.map((t) =>
t.uriTemplate === "file:///users/{userId}/profile"
? { ...t, selected: true }
: t,
),
subscriptions: sampleSubscriptions,
selectedTemplate: selectedTemplateData,
selectedResource: {
uri: "file:///users/42/profile",
mimeType: "application/json",
annotations: { audience: "developer", priority: 0.8 },
content: JSON.stringify(
{ id: 42, name: "Alice", email: "alice@example.com" },
null,
2,
),
lastUpdated: "2026-03-17T11:15:00Z",
isSubscribed: false,
},
},
};
export const Empty: Story = {
args: {
resources: [],

View file

@ -1,17 +1,6 @@
import {
Accordion,
Card,
Container,
Grid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ResourceListItem } from "../../groups/ResourceListItem/ResourceListItem";
import { Card, Flex, ScrollArea, Stack, Text } from "@mantine/core";
import { ResourceControls } from "../../groups/ResourceControls/ResourceControls";
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
import { ResourceSubscribedItem } from "../../groups/ResourceSubscribedItem/ResourceSubscribedItem";
import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel";
export interface ResourceItem {
@ -68,15 +57,21 @@ export interface ResourcesScreenProps {
onUnsubscribeResource: (uri: string) => void;
}
const PageContainer = Container.withProps({
size: "xl",
py: "xl",
const ScreenLayout = Flex.withProps({
variant: "screen",
h: "calc(100vh - var(--app-shell-header-height, 0px))",
gap: "xl",
p: "xl",
});
const FullHeightCard = Card.withProps({
const Sidebar = Stack.withProps({
w: 340,
flex: "0 0 auto",
});
const SidebarCard = Card.withProps({
withBorder: true,
padding: "lg",
h: "100%",
});
const DetailCard = Card.withProps({
@ -90,14 +85,6 @@ const EmptyState = Text.withProps({
py: "xl",
});
function formatSectionCount(label: string, count: number): string {
return `${label} (${count})`;
}
function templateDisplayName(item: TemplateListItem): string {
return item.title ?? item.name;
}
export function ResourcesScreen({
resources,
templates,
@ -114,138 +101,71 @@ export function ResourcesScreen({
onSubscribeResource,
onUnsubscribeResource,
}: ResourcesScreenProps) {
const filteredResources = resources.filter((r) =>
r.name.toLowerCase().includes(searchText.toLowerCase()),
);
const openSections = [
...(filteredResources.length > 0 ? ["resources"] : []),
...(templates.length > 0 ? ["templates"] : []),
...(subscriptions.length > 0 ? ["subscriptions"] : []),
];
return (
<PageContainer>
<Grid align="stretch">
<Grid.Col span={4}>
<FullHeightCard>
<Stack gap="sm">
<Title order={4}>Resources</Title>
<ListChangedIndicator
visible={listChanged}
onRefresh={onRefreshList}
/>
<TextInput
placeholder="Search..."
value={searchText}
onChange={(e) => onSearchChange(e.currentTarget.value)}
/>
<Accordion multiple defaultValue={openSections}>
<Accordion.Item value="resources">
<Accordion.Control disabled={filteredResources.length === 0}>
{formatSectionCount("URIs", filteredResources.length)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredResources.map((resource) => (
<ResourceListItem
key={resource.uri}
name={resource.name}
uri={resource.uri}
annotations={resource.annotations}
selected={resource.selected}
onClick={() => onSelectUri(resource.uri)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<ScreenLayout>
<Sidebar>
<SidebarCard>
<ResourceControls
resources={resources}
templates={templates}
subscriptions={subscriptions}
listChanged={listChanged}
searchText={searchText}
onSearchChange={onSearchChange}
onRefreshList={onRefreshList}
onSelectUri={onSelectUri}
onSelectTemplate={onSelectTemplate}
onUnsubscribeResource={onUnsubscribeResource}
/>
</SidebarCard>
</Sidebar>
<Accordion.Item value="templates">
<Accordion.Control disabled={templates.length === 0}>
{formatSectionCount("Templates", templates.length)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{templates.map((template) => (
<ResourceListItem
key={template.uriTemplate}
name={templateDisplayName(template)}
uri={template.uriTemplate}
selected={template.selected}
onClick={() => onSelectTemplate(template.uriTemplate)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="subscriptions">
<Accordion.Control disabled={subscriptions.length === 0}>
{formatSectionCount("Subscriptions", subscriptions.length)}
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{subscriptions.map((sub) => (
<ResourceSubscribedItem
key={sub.name}
name={sub.name}
lastUpdated={sub.lastUpdated}
onUnsubscribe={() => onUnsubscribeResource(sub.uri)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</FullHeightCard>
</Grid.Col>
<Grid.Col span={8}>
<Stack gap="md">
{selectedTemplate ? (
<>
<DetailCard>
<ResourceTemplatePanel
{...selectedTemplate}
onReadResource={onReadResource}
/>
</DetailCard>
{selectedResource && (
<DetailCard>
<ResourcePreviewPanel
{...selectedResource}
onRefresh={() => onReadResource(selectedResource.uri)}
onSubscribe={() =>
onSubscribeResource(selectedResource.uri)
}
onUnsubscribe={() =>
onUnsubscribeResource(selectedResource.uri)
}
/>
</DetailCard>
)}
</>
) : selectedResource ? (
<ScrollArea.Autosize
flex={1}
mah="calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)"
>
<Stack gap="md">
{selectedTemplate ? (
<>
<DetailCard>
<ResourcePreviewPanel
{...selectedResource}
onRefresh={() => onReadResource(selectedResource.uri)}
onSubscribe={() => onSubscribeResource(selectedResource.uri)}
onUnsubscribe={() =>
onUnsubscribeResource(selectedResource.uri)
}
<ResourceTemplatePanel
{...selectedTemplate}
onReadResource={onReadResource}
/>
</DetailCard>
) : (
<DetailCard>
<EmptyState>Select a resource to preview</EmptyState>
</DetailCard>
)}
</Stack>
</Grid.Col>
</Grid>
</PageContainer>
{selectedResource && (
<DetailCard>
<ResourcePreviewPanel
{...selectedResource}
onRefresh={() => onReadResource(selectedResource.uri)}
onSubscribe={() =>
onSubscribeResource(selectedResource.uri)
}
onUnsubscribe={() =>
onUnsubscribeResource(selectedResource.uri)
}
/>
</DetailCard>
)}
</>
) : selectedResource ? (
<DetailCard>
<ResourcePreviewPanel
{...selectedResource}
onRefresh={() => onReadResource(selectedResource.uri)}
onSubscribe={() => onSubscribeResource(selectedResource.uri)}
onUnsubscribe={() =>
onUnsubscribeResource(selectedResource.uri)
}
/>
</DetailCard>
) : (
<DetailCard>
<EmptyState>Select a resource to preview</EmptyState>
</DetailCard>
)}
</Stack>
</ScrollArea.Autosize>
</ScreenLayout>
);
}

View file

@ -262,6 +262,91 @@ export const ResourcesActive: Story = {
annotations: { priority: 0.5 },
selected: false,
},
{
name: "package.json",
uri: "file:///package.json",
annotations: { audience: "developer" },
selected: false,
},
{
name: "tsconfig.json",
uri: "file:///tsconfig.json",
annotations: { audience: "developer", priority: 0.3 },
selected: false,
},
{
name: ".env.example",
uri: "file:///.env.example",
selected: false,
},
{
name: "docker-compose.yml",
uri: "file:///docker-compose.yml",
annotations: { priority: 0.6 },
selected: false,
},
{
name: "migrations/001_init.sql",
uri: "file:///migrations/001_init.sql",
annotations: { audience: "developer", priority: 0.4 },
selected: false,
},
{
name: "migrations/002_add_users.sql",
uri: "file:///migrations/002_add_users.sql",
annotations: { audience: "developer", priority: 0.4 },
selected: false,
},
{
name: "seeds/users.json",
uri: "file:///seeds/users.json",
selected: false,
},
{
name: "seeds/products.json",
uri: "file:///seeds/products.json",
selected: false,
},
{
name: "certs/server.pem",
uri: "file:///certs/server.pem",
annotations: { priority: 0.9 },
selected: false,
},
{
name: "logs/access.log",
uri: "file:///logs/access.log",
annotations: { audience: "application", priority: 0.2 },
selected: false,
},
{
name: "logs/error.log",
uri: "file:///logs/error.log",
annotations: { audience: "application", priority: 0.7 },
selected: false,
},
{
name: "api-spec.yaml",
uri: "file:///api-spec.yaml",
annotations: { audience: "developer" },
selected: false,
},
{
name: "CHANGELOG.md",
uri: "file:///CHANGELOG.md",
selected: false,
},
{
name: "LICENSE",
uri: "file:///LICENSE",
selected: false,
},
{
name: "Makefile",
uri: "file:///Makefile",
annotations: { audience: "developer", priority: 0.3 },
selected: false,
},
]}
templates={[
{
@ -275,6 +360,34 @@ export const ResourcesActive: Story = {
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
selected: false,
},
{
name: "Log File",
title: "Application Log",
uriTemplate: "file:///logs/{service}/{date}.log",
selected: false,
},
{
name: "Migration",
uriTemplate: "file:///migrations/{version}_{name}.sql",
selected: false,
},
{
name: "Config by Environment",
title: "Environment Config",
uriTemplate: "file:///config/{environment}.json",
selected: false,
},
{
name: "API Endpoint",
uriTemplate: "https://api.example.com/{version}/{resource}",
selected: false,
},
{
name: "Report",
title: "Generated Report",
uriTemplate: "reports://{reportType}/{year}/{month}",
selected: false,
},
]}
subscriptions={[
{
@ -282,6 +395,39 @@ export const ResourcesActive: Story = {
uri: "file:///config.json",
lastUpdated: "2026-03-17T10:30:00Z",
},
{
name: "schema.sql",
uri: "file:///schema.sql",
lastUpdated: "2026-03-17T10:28:00Z",
},
{
name: "docker-compose.yml",
uri: "file:///docker-compose.yml",
lastUpdated: "2026-03-17T09:45:00Z",
},
{
name: "logs/error.log",
uri: "file:///logs/error.log",
lastUpdated: "2026-03-17T10:31:12Z",
},
{
name: "certs/server.pem",
uri: "file:///certs/server.pem",
},
{
name: "api-spec.yaml",
uri: "file:///api-spec.yaml",
lastUpdated: "2026-03-17T08:15:00Z",
},
{
name: "package.json",
uri: "file:///package.json",
lastUpdated: "2026-03-17T10:22:00Z",
},
{
name: "seeds/users.json",
uri: "file:///seeds/users.json",
},
]}
selectedTemplate={{
name: "User Profile",