Redesign Resources screen: template panel, subscriptions, hover states, and callback architecture
- Rename ResourceTemplateInput to ResourceTemplatePanel with panel layout (title header, URI with live variable preview, labeled inputs, Read Resource button) - ResourceTemplatePanel manages its own variable state via useState; single onReadResource(uri) callback returns fully resolved URI - Extract ResourceSubscribedItem group component with name, timestamp, and subtle Unsubscribe button - Add hover state to listItem variant via theme classNames + App.css pseudo-selector - Add Refresh button to ResourcePreviewPanel - Refactor ResourcesScreen callbacks: onSelectUri, onSelectTemplate, onReadResource, onSubscribeResource, onUnsubscribeResource replace per-item onClick/onUnsubscribe props; screen wires callbacks to children - Define data-only types (ResourceItem, TemplateListItem, SubscriptionItem, SelectedResource, SelectedTemplate) separating data from callbacks - Extract named .withProps() subcomponents (FooterRow, AnnotationGroup, ActionGroup, Spacer) for AGENTS.md compliance - Update all stories (ResourceTemplatePanel, ResourcesScreen, ConnectedView) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee89ec9beb
commit
a0e763a315
2 changed files with 520 additions and 200 deletions
|
|
@ -1,9 +1,12 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { ResourcesScreen } from "./ResourcesScreen";
|
||||
import type { ResourceListItemProps } from "../../groups/ResourceListItem/ResourceListItem";
|
||||
import type { ResourceTemplateInputProps } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
|
||||
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
|
||||
import type {
|
||||
ResourceItem,
|
||||
TemplateListItem,
|
||||
SelectedResource,
|
||||
SelectedTemplate,
|
||||
} from "./ResourcesScreen";
|
||||
|
||||
const meta: Meta<typeof ResourcesScreen> = {
|
||||
title: "Screens/ResourcesScreen",
|
||||
|
|
@ -13,7 +16,11 @@ const meta: Meta<typeof ResourcesScreen> = {
|
|||
searchText: "",
|
||||
onSearchChange: fn(),
|
||||
onRefreshList: fn(),
|
||||
onSelectResource: fn(),
|
||||
onSelectUri: fn(),
|
||||
onSelectTemplate: fn(),
|
||||
onReadResource: fn(),
|
||||
onSubscribeResource: fn(),
|
||||
onUnsubscribeResource: fn(),
|
||||
listChanged: false,
|
||||
subscriptions: [],
|
||||
templates: [],
|
||||
|
|
@ -23,45 +30,41 @@ const meta: Meta<typeof ResourcesScreen> = {
|
|||
export default meta;
|
||||
type Story = StoryObj<typeof ResourcesScreen>;
|
||||
|
||||
const sampleResources: ResourceListItemProps[] = [
|
||||
const sampleResources: ResourceItem[] = [
|
||||
{
|
||||
name: "config.json",
|
||||
uri: "file:///config.json",
|
||||
annotations: { audience: "developer", priority: 0.8 },
|
||||
selected: false,
|
||||
onClick: fn(),
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
uri: "file:///README.md",
|
||||
selected: false,
|
||||
onClick: fn(),
|
||||
},
|
||||
{
|
||||
name: "schema.sql",
|
||||
uri: "file:///schema.sql",
|
||||
annotations: { priority: 0.5 },
|
||||
selected: false,
|
||||
onClick: fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const sampleTemplates: ResourceTemplateInputProps[] = [
|
||||
const sampleTemplates: TemplateListItem[] = [
|
||||
{
|
||||
template: "file:///users/{userId}/profile",
|
||||
variables: { userId: "" },
|
||||
onVariableChange: fn(),
|
||||
onSubmit: fn(),
|
||||
name: "User Profile",
|
||||
uriTemplate: "file:///users/{userId}/profile",
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
template: "db://tables/{tableName}/rows/{rowId}",
|
||||
variables: { tableName: "", rowId: "" },
|
||||
onVariableChange: fn(),
|
||||
onSubmit: fn(),
|
||||
name: "Table Row",
|
||||
title: "Database Table Row",
|
||||
uriTemplate: "db://tables/{tableName}/rows/{rowId}",
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedResourceProps: ResourcePreviewPanelProps = {
|
||||
const selectedResourceData: SelectedResource = {
|
||||
uri: "file:///config.json",
|
||||
mimeType: "application/json",
|
||||
annotations: { audience: "developer", priority: 0.8 },
|
||||
|
|
@ -76,8 +79,12 @@ const selectedResourceProps: ResourcePreviewPanelProps = {
|
|||
),
|
||||
lastUpdated: "2026-03-17T10:30:00Z",
|
||||
isSubscribed: true,
|
||||
onSubscribe: fn(),
|
||||
onUnsubscribe: fn(),
|
||||
};
|
||||
|
||||
const selectedTemplateData: SelectedTemplate = {
|
||||
name: "User Profile",
|
||||
uriTemplate: "file:///users/{userId}/profile",
|
||||
description: "Fetch a user profile by their unique identifier.",
|
||||
};
|
||||
|
||||
export const WithResources: Story = {
|
||||
|
|
@ -91,9 +98,13 @@ export const ResourceSelected: Story = {
|
|||
resources: sampleResources.map((r) =>
|
||||
r.uri === "file:///config.json" ? { ...r, selected: true } : r,
|
||||
),
|
||||
selectedResource: selectedResourceProps,
|
||||
selectedResource: selectedResourceData,
|
||||
subscriptions: [
|
||||
{ name: "config.json", lastUpdated: "2026-03-17T10:30:00Z" },
|
||||
{
|
||||
name: "config.json",
|
||||
uri: "file:///config.json",
|
||||
lastUpdated: "2026-03-17T10:30:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -105,6 +116,42 @@ export const WithTemplates: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const TemplateSelected: Story = {
|
||||
args: {
|
||||
resources: sampleResources,
|
||||
templates: sampleTemplates.map((t) =>
|
||||
t.uriTemplate === "file:///users/{userId}/profile"
|
||||
? { ...t, selected: true }
|
||||
: t,
|
||||
),
|
||||
selectedTemplate: selectedTemplateData,
|
||||
},
|
||||
};
|
||||
|
||||
export const TemplateWithResource: Story = {
|
||||
args: {
|
||||
resources: sampleResources,
|
||||
templates: sampleTemplates.map((t) =>
|
||||
t.uriTemplate === "file:///users/{userId}/profile"
|
||||
? { ...t, selected: true }
|
||||
: t,
|
||||
),
|
||||
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: [],
|
||||
|
|
|
|||
|
|
@ -11,26 +11,61 @@ import {
|
|||
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||
import { ResourceListItem } from "../../groups/ResourceListItem/ResourceListItem";
|
||||
import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
|
||||
import { ResourceTemplateInput } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
|
||||
import type { ResourceListItemProps } from "../../groups/ResourceListItem/ResourceListItem";
|
||||
import type { ResourcePreviewPanelProps } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel";
|
||||
import type { ResourceTemplateInputProps } from "../../groups/ResourceTemplateInput/ResourceTemplateInput";
|
||||
import { ResourceSubscribedItem } from "../../groups/ResourceSubscribedItem/ResourceSubscribedItem";
|
||||
import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel";
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
export interface ResourceItem {
|
||||
name: string;
|
||||
uri: string;
|
||||
annotations?: { audience?: string; priority?: number };
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateListItem {
|
||||
name: string;
|
||||
title?: string;
|
||||
uriTemplate: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionItem {
|
||||
name: string;
|
||||
uri: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface SelectedResource {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
annotations?: { audience?: string; priority?: number };
|
||||
content: string;
|
||||
lastUpdated?: string;
|
||||
isSubscribed: boolean;
|
||||
}
|
||||
|
||||
export interface SelectedTemplate {
|
||||
name: string;
|
||||
title?: string;
|
||||
uriTemplate: string;
|
||||
description?: string;
|
||||
annotations?: { audience?: string; priority?: number };
|
||||
}
|
||||
|
||||
export interface ResourcesScreenProps {
|
||||
resources: ResourceListItemProps[];
|
||||
templates: ResourceTemplateInputProps[];
|
||||
subscriptions: SubscriptionInfo[];
|
||||
selectedResource?: ResourcePreviewPanelProps;
|
||||
resources: ResourceItem[];
|
||||
templates: TemplateListItem[];
|
||||
subscriptions: SubscriptionItem[];
|
||||
selectedResource?: SelectedResource;
|
||||
selectedTemplate?: SelectedTemplate;
|
||||
listChanged: boolean;
|
||||
searchText: string;
|
||||
onSearchChange: (text: string) => void;
|
||||
onRefreshList: () => void;
|
||||
onSelectResource: (uri: string) => void;
|
||||
onSelectUri: (uri: string) => void;
|
||||
onSelectTemplate: (uriTemplate: string) => void;
|
||||
onReadResource: (uri: string) => void;
|
||||
onSubscribeResource: (uri: string) => void;
|
||||
onUnsubscribeResource: (uri: string) => void;
|
||||
}
|
||||
|
||||
const PageContainer = Container.withProps({
|
||||
|
|
@ -55,18 +90,12 @@ const EmptyState = Text.withProps({
|
|||
py: "xl",
|
||||
});
|
||||
|
||||
const SubscriptionMeta = Text.withProps({
|
||||
span: true,
|
||||
c: "dimmed",
|
||||
size: "xs",
|
||||
});
|
||||
|
||||
function formatSectionCount(label: string, count: number): string {
|
||||
return `${label} (${count})`;
|
||||
}
|
||||
|
||||
function formatLastUpdated(lastUpdated: string): string {
|
||||
return ` — ${lastUpdated}`;
|
||||
function templateDisplayName(item: TemplateListItem): string {
|
||||
return item.title ?? item.name;
|
||||
}
|
||||
|
||||
export function ResourcesScreen({
|
||||
|
|
@ -74,10 +103,16 @@ export function ResourcesScreen({
|
|||
templates,
|
||||
subscriptions,
|
||||
selectedResource,
|
||||
selectedTemplate,
|
||||
listChanged,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
onRefreshList,
|
||||
onSelectUri,
|
||||
onSelectTemplate,
|
||||
onReadResource,
|
||||
onSubscribeResource,
|
||||
onUnsubscribeResource,
|
||||
}: ResourcesScreenProps) {
|
||||
const filteredResources = resources.filter((r) =>
|
||||
r.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
|
|
@ -113,7 +148,14 @@ export function ResourcesScreen({
|
|||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{filteredResources.map((resource) => (
|
||||
<ResourceListItem key={resource.uri} {...resource} />
|
||||
<ResourceListItem
|
||||
key={resource.uri}
|
||||
name={resource.name}
|
||||
uri={resource.uri}
|
||||
annotations={resource.annotations}
|
||||
selected={resource.selected}
|
||||
onClick={() => onSelectUri(resource.uri)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
|
|
@ -126,9 +168,12 @@ export function ResourcesScreen({
|
|||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{templates.map((template) => (
|
||||
<ResourceTemplateInput
|
||||
key={template.template}
|
||||
{...template}
|
||||
<ResourceListItem
|
||||
key={template.uriTemplate}
|
||||
name={templateDisplayName(template)}
|
||||
uri={template.uriTemplate}
|
||||
selected={template.selected}
|
||||
onClick={() => onSelectTemplate(template.uriTemplate)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
|
@ -142,14 +187,12 @@ export function ResourcesScreen({
|
|||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{subscriptions.map((sub) => (
|
||||
<Text key={sub.name} size="sm">
|
||||
{sub.name}
|
||||
{sub.lastUpdated && (
|
||||
<SubscriptionMeta>
|
||||
{formatLastUpdated(sub.lastUpdated)}
|
||||
</SubscriptionMeta>
|
||||
)}
|
||||
</Text>
|
||||
<ResourceSubscribedItem
|
||||
key={sub.name}
|
||||
name={sub.name}
|
||||
lastUpdated={sub.lastUpdated}
|
||||
onUnsubscribe={() => onUnsubscribeResource(sub.uri)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
|
|
@ -160,13 +203,47 @@ export function ResourcesScreen({
|
|||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={8}>
|
||||
<DetailCard>
|
||||
{selectedResource ? (
|
||||
<ResourcePreviewPanel {...selectedResource} />
|
||||
<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 ? (
|
||||
<DetailCard>
|
||||
<ResourcePreviewPanel
|
||||
{...selectedResource}
|
||||
onRefresh={() => onReadResource(selectedResource.uri)}
|
||||
onSubscribe={() => onSubscribeResource(selectedResource.uri)}
|
||||
onUnsubscribe={() =>
|
||||
onUnsubscribeResource(selectedResource.uri)
|
||||
}
|
||||
/>
|
||||
</DetailCard>
|
||||
) : (
|
||||
<EmptyState>Select a resource to preview</EmptyState>
|
||||
<DetailCard>
|
||||
<EmptyState>Select a resource to preview</EmptyState>
|
||||
</DetailCard>
|
||||
)}
|
||||
</DetailCard>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</PageContainer>
|
||||
|
|
|
|||
Loading…
Reference in a new issue