From eca6cf7665e0ecd16fbe70f49124e23b8c2fb36a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 31 Mar 2026 14:38:02 -0400 Subject: [PATCH] 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) --- .../ResourceControls.stories.tsx | 72 ++++++ .../ResourceControls/ResourceControls.tsx | 144 +++++++++++ .../ResourcesScreen.stories.tsx | 197 ++++++++++++++- .../ResourcesScreen/ResourcesScreen.tsx | 228 ++++++------------ .../ConnectedView/ConnectedView.stories.tsx | 146 +++++++++++ 5 files changed, 626 insertions(+), 161 deletions(-) create mode 100644 clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx create mode 100644 clients/web/src/components/groups/ResourceControls/ResourceControls.tsx diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx new file mode 100644 index 00000000..d4a72c34 --- /dev/null +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { ResourceControls } from "./ResourceControls"; + +const meta: Meta = { + 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; + +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: [], + }, +}; diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx new file mode 100644 index 00000000..367b5ed1 --- /dev/null +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -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(defaultOpen); + const maxHeight = panelMaxHeight(openSections.length); + + return ( + + Resources + + onSearchChange(e.currentTarget.value)} + /> + + + + {formatSectionCount("URIs", filteredResources.length)} + + + + + {filteredResources.map((resource) => ( + onSelectUri(resource.uri)} + /> + ))} + + + + + + + + {formatSectionCount("Templates", templates.length)} + + + + + {templates.map((template) => ( + onSelectTemplate(template.uriTemplate)} + /> + ))} + + + + + + + + {formatSectionCount("Subscriptions", subscriptions.length)} + + + + + {subscriptions.map((sub) => ( + onUnsubscribeResource(sub.uri)} + /> + ))} + + + + + + + ); +} diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index 7bc77191..c8b2ff7a 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -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: [], diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index d69cc002..6eab615b 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -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 ( - - - - - - Resources - - onSearchChange(e.currentTarget.value)} - /> - - - - {formatSectionCount("URIs", filteredResources.length)} - - - - {filteredResources.map((resource) => ( - onSelectUri(resource.uri)} - /> - ))} - - - + + + + + + - - - {formatSectionCount("Templates", templates.length)} - - - - {templates.map((template) => ( - onSelectTemplate(template.uriTemplate)} - /> - ))} - - - - - - - {formatSectionCount("Subscriptions", subscriptions.length)} - - - - {subscriptions.map((sub) => ( - onUnsubscribeResource(sub.uri)} - /> - ))} - - - - - - - - - - - {selectedTemplate ? ( - <> - - - - {selectedResource && ( - - onReadResource(selectedResource.uri)} - onSubscribe={() => - onSubscribeResource(selectedResource.uri) - } - onUnsubscribe={() => - onUnsubscribeResource(selectedResource.uri) - } - /> - - )} - - ) : selectedResource ? ( + + + {selectedTemplate ? ( + <> - onReadResource(selectedResource.uri)} - onSubscribe={() => onSubscribeResource(selectedResource.uri)} - onUnsubscribe={() => - onUnsubscribeResource(selectedResource.uri) - } + - ) : ( - - Select a resource to preview - - )} - - - - + {selectedResource && ( + + onReadResource(selectedResource.uri)} + onSubscribe={() => + onSubscribeResource(selectedResource.uri) + } + onUnsubscribe={() => + onUnsubscribeResource(selectedResource.uri) + } + /> + + )} + + ) : selectedResource ? ( + + onReadResource(selectedResource.uri)} + onSubscribe={() => onSubscribeResource(selectedResource.uri)} + onUnsubscribe={() => + onUnsubscribeResource(selectedResource.uri) + } + /> + + ) : ( + + Select a resource to preview + + )} + + + ); } diff --git a/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx b/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx index 429e1005..ed6f1e17 100644 --- a/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx +++ b/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx @@ -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",