Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions apps/portal/src/components/ResourcePicker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import type { ResourceBindingSpec, ResourceDocument, ResourceRevisionDocument } from "@/types";
import { getSelectedProjectId, setSelectedProjectIdHolder } from "@/lib/project-scope";
import { ResourcePicker } from "./ResourcePicker";

const resource: ResourceDocument = {
_id: "resource-1",
projectId: "demo-project",
slug: "github-simulator",
name: "GitHub simulator",
revisionCounter: 1,
latestRevisionId: "resource-revision-1",
latestRevisionNumber: 1,
createdAt: "2026-01-01T00:00:00.000Z",
};

const revision: ResourceRevisionDocument = {
_id: "resource-revision-1",
resourceId: resource._id,
projectId: resource.projectId,
slug: resource.slug,
revisionNumber: 1,
ref: "github-simulator@r1",
setup: { sh: "echo ready" },
exports: ["SIMULATOR_URL"],
parameters: [
{ name: "REPO", required: true, description: "Repository to simulate" },
{ name: "SCENARIO", required: false, default: "default-scenario" },
],
contentSha256: "sha",
createdAt: "2026-01-01T00:00:00.000Z",
};

function ResourcePickerHarness({
initial,
profileBindings = [],
bindingsLocked = false,
disabled = false,
}: {
initial: ResourceBindingSpec[];
profileBindings?: ResourceBindingSpec[];
bindingsLocked?: boolean;
disabled?: boolean;
}) {
const [selected, setSelected] = useState(initial);
const [scopeReady, setScopeReady] = useState(false);

useEffect(() => {
const previousProjectId = getSelectedProjectId();
setSelectedProjectIdHolder("demo-project");
setScopeReady(true);
return () => setSelectedProjectIdHolder(previousProjectId);
}, []);

if (!scopeReady) return null;

return (
<div className="w-[44rem] max-w-full p-6">
<ResourcePicker
selected={selected}
onChange={setSelected}
profileBindings={profileBindings}
bindingsLocked={bindingsLocked}
disabled={disabled}
/>
</div>
);
}

const profileBindings: ResourceBindingSpec[] = [{
ref: revision.ref,
params: { REPO: "microsoft/scope" },
}];

const meta = {
title: "Components/ResourcePicker",
component: ResourcePicker,
parameters: {
msw: {
handlers: [
http.get("*/api/v1/resources", () => HttpResponse.json([resource])),
http.get("*/api/v1/resources/:resourceId/revisions", () => HttpResponse.json([revision])),
],
},
},
} satisfies Meta<typeof ResourcePicker>;

export default meta;
type Story = StoryObj<typeof meta>;

export const RunOwned: Story = {
args: {
selected: [],
onChange: () => {},
},
render: () => <ResourcePickerHarness initial={[]} />,
};

export const ProfileManaged: Story = {
args: {
selected: profileBindings,
profileBindings,
bindingsLocked: true,
onChange: () => {},
},
render: () => (
<ResourcePickerHarness
initial={profileBindings}
profileBindings={profileBindings}
bindingsLocked
/>
),
};

export const VariationModeLocked: Story = {
args: {
selected: profileBindings,
profileBindings,
bindingsLocked: true,
disabled: true,
onChange: () => {},
},
render: () => (
<ResourcePickerHarness
initial={profileBindings}
profileBindings={profileBindings}
bindingsLocked
disabled
/>
),
};
5 changes: 5 additions & 0 deletions apps/portal/src/components/ResourcePicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ afterEach(() => {
function renderPicker(props: {
selected: ResourceBindingSpec[];
profileBindings?: ResourceBindingSpec[];
bindingsLocked?: boolean;
onChange?: (bindings: ResourceBindingSpec[]) => void;
}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
Expand All @@ -73,6 +74,7 @@ function renderPicker(props: {
<ResourcePicker
selected={props.selected}
profileBindings={props.profileBindings}
bindingsLocked={props.bindingsLocked}
onChange={props.onChange ?? vi.fn()}
/>
</QueryClientProvider>,
Expand All @@ -87,6 +89,7 @@ describe("ResourcePicker parameter bindings", () => {
renderPicker({
selected,
profileBindings: selected,
bindingsLocked: true,
onChange,
});

Expand All @@ -98,6 +101,8 @@ describe("ResourcePicker parameter bindings", () => {
const scenarioInput = screen.getByLabelText("SCENARIO") as HTMLInputElement;
expect(scenarioInput.disabled).toBe(false);
expect(scenarioInput.value).toBe("default-scenario");
expect(screen.queryByPlaceholderText("Search resources…")).toBeNull();
expect(screen.queryByText("Create a resource")).toBeNull();

fireEvent.change(scenarioInput, { target: { value: "custom-scenario" } });

Expand Down
26 changes: 20 additions & 6 deletions apps/portal/src/components/ResourcePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ interface ResourcePickerProps {
selected: ResourceBindingSpec[];
onChange: (specs: ResourceBindingSpec[]) => void;
profileBindings?: ResourceBindingSpec[];
/** Prevent adding, removing, or replacing bindings while leaving run-owned parameter gaps editable. */
bindingsLocked?: boolean;
disabled?: boolean;
}

Expand All @@ -26,7 +28,13 @@ export function parseResourceRef(ref: string): { slug: string; revisionRef?: str
return { slug: ref };
}

export function ResourcePicker({ selected, onChange, profileBindings = [], disabled = false }: ResourcePickerProps) {
export function ResourcePicker({
selected,
onChange,
profileBindings = [],
bindingsLocked = false,
disabled = false,
}: ResourcePickerProps) {
const queryClient = useQueryClient();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -72,7 +80,8 @@ export function ResourcePicker({ selected, onChange, profileBindings = [], disab
onChange(selected.filter((_, i) => i !== index));
};

const showDropdown = open && (matches.length > 0 || query.trim().length > 0);
const canChangeBindings = !disabled && !bindingsLocked;
const showDropdown = canChangeBindings && open && (matches.length > 0 || query.trim().length > 0);

if (isLoading) return <Skeleton className="h-9 w-full" />;

Expand All @@ -90,6 +99,7 @@ export function ResourcePicker({ selected, onChange, profileBindings = [], disab
binding={binding}
profileBinding={findProfileBinding(binding, profileBindings)}
resources={activeResources}
bindingLocked={bindingsLocked}
disabled={disabled}
onReplace={(next) => replaceBinding(index, { ref: next })}
onChange={(next) => replaceBinding(index, next)}
Expand All @@ -99,7 +109,7 @@ export function ResourcePicker({ selected, onChange, profileBindings = [], disab
</div>
)}

{!disabled && (
{canChangeBindings && (
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
Expand Down Expand Up @@ -142,7 +152,7 @@ export function ResourcePicker({ selected, onChange, profileBindings = [], disab
</div>
)}

{!disabled && (
{canChangeBindings && (
<div className="mt-1.5">
<button type="button" onClick={() => setCreateOpen(!createOpen)} className="flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
{createOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
Expand Down Expand Up @@ -174,6 +184,7 @@ function SelectedResource({
binding,
profileBinding,
resources,
bindingLocked,
disabled,
onReplace,
onChange,
Expand All @@ -182,6 +193,7 @@ function SelectedResource({
binding: ResourceBindingSpec;
profileBinding?: ResourceBindingSpec;
resources: ResourceDocument[];
bindingLocked: boolean;
disabled: boolean;
onReplace: (spec: string) => void;
onChange: (binding: ResourceBindingSpec) => void;
Expand All @@ -208,7 +220,7 @@ function SelectedResource({
return revisions[0] ?? null;
}, [parsed.revisionRef, resource, revisions]);

const profileControlledResource = !!profileBinding;
const profileControlledResource = bindingLocked || !!profileBinding;
const updateParam = (parameter: ResourceParameter, value: string) => {
const params = { ...(binding.params ?? {}) };
if (value === "" && parameter.default === undefined) {
Expand All @@ -228,7 +240,9 @@ function SelectedResource({
<Boxes className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 font-mono text-xs">{binding.ref}</span>
<Badge variant="secondary" className="text-[10px]">resolved revision</Badge>
{!disabled && <X className="h-3 w-3 cursor-pointer text-muted-foreground hover:text-destructive" onClick={onRemove} />}
{!disabled && !profileControlledResource && (
<X className="h-3 w-3 cursor-pointer text-muted-foreground hover:text-destructive" onClick={onRemove} />
)}
</div>
);
}
Expand Down
Loading
Loading