Skip to content
Draft
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
76 changes: 76 additions & 0 deletions src/components/shared/ComponentDiff/DiffSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import type { EntityDiff } from "@/utils/componentSpecDiff";

/**
* Renders the lost / added / changed entities of an `EntityDiff` as a compact,
* color-coded list. Shared by the legacy and v2 editors (upgrade flow, replace
* confirmation, and the edit-component save modal). Only reads `name`, so it
* accepts a diff of any name-keyed entity.
*/
export function DiffSection({
label,
diff,
}: {
label: string;
diff: EntityDiff<{ name: string }>;
}) {
const hasChanges =
diff.lostEntities.length > 0 ||
diff.newEntities.length > 0 ||
diff.changedEntities.length > 0;

if (!hasChanges) return null;

return (
<BlockStack gap="1">
<Text size="xs" weight="semibold" tone="subdued">
{label} Changes
</Text>
<BlockStack className="gap-0.5">
{diff.lostEntities.map((e) => (
<DiffLine
key={`lost-${e.name}`}
icon="Minus"
color="text-red-500"
label={`Removed: ${e.name}`}
/>
))}
{diff.newEntities.map((e) => (
<DiffLine
key={`new-${e.name}`}
icon="Plus"
color="text-green-600"
label={`Added: ${e.name}`}
/>
))}
{diff.changedEntities.map((e) => (
<DiffLine
key={`changed-${e.name}`}
icon="RefreshCw"
color="text-amber-500"
label={`Changed: ${e.name}`}
/>
))}
</BlockStack>
</BlockStack>
);
}

function DiffLine({
icon,
color,
label,
}: {
icon: "Minus" | "Plus" | "RefreshCw";
color: string;
label: string;
}) {
return (
<InlineStack gap="1" blockAlign="start">
<Icon name={icon} size="xs" className={`${color} mt-0.5 shrink-0`} />
<Text size="xs">{label}</Text>
</InlineStack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,10 @@ describe("<ComponentEditorDialog />", () => {
fireEvent.click(screen.getByRole("button", { name: /Save/i }));

await waitFor(() => {
// The edited component is handed to the caller to apply.
// The edited component is handed to the caller to apply in place.
expect(onComponentSavedMock).toHaveBeenCalledWith(
mockHydratedComponent,
"update",
);
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
Expand All @@ -386,5 +387,146 @@ describe("<ComponentEditorDialog />", () => {
"success",
);
});

const renderActionsWith = (label: string, action: string) =>
vi.fn(
({
onChoose,
}: {
onChoose: (a: "update" | "import" | "place") => void;
}) => (
<button
onClick={() => onChoose(action as "update" | "import" | "place")}
>
{label}
</button>
),
);

test("renderSaveActions: Save swaps to the actions view and defers application", async () => {
const onCloseMock = vi.fn();
const onComponentSavedMock = vi.fn();
const renderSaveActions = renderActionsWith("do-update", "update");
const mockHydratedComponent = {
spec: { implementation: { container: { image: "test" } } },
name: "test-component",
digest: "abc123",
text: "name: test-component",
};
vi.mocked(hydrateComponentReference).mockResolvedValue(
mockHydratedComponent,
);

renderWithProviders(
<ComponentEditorDialog
text="name: test-component"
onClose={onCloseMock}
onComponentSaved={onComponentSavedMock}
renderSaveActions={renderSaveActions}
/>,
);

await waitFor(() => {
expect(
screen.getByRole("button", { name: /Save/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Save/i }));

// The actions view is shown; nothing is applied or closed yet.
await waitFor(() => {
expect(
screen.getByRole("button", { name: "do-update" }),
).toBeInTheDocument();
});
expect(onCloseMock).not.toHaveBeenCalled();
expect(onComponentSavedMock).not.toHaveBeenCalled();

// Choosing "update" applies via onComponentSaved and closes.
fireEvent.click(screen.getByRole("button", { name: "do-update" }));
await waitFor(() => {
expect(onComponentSavedMock).toHaveBeenCalledWith(
mockHydratedComponent,
"update",
);
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
});

test("renderSaveActions: choosing 'import' runs the library path, not onComponentSaved", async () => {
const onCloseMock = vi.fn();
const onComponentSavedMock = vi.fn();
const renderSaveActions = renderActionsWith("do-import", "import");
const mockHydratedComponent = {
spec: { implementation: { container: { image: "test" } } },
name: "test-component",
digest: "abc123",
text: "name: test-component",
};
vi.mocked(hydrateComponentReference).mockResolvedValue(
mockHydratedComponent,
);

renderWithProviders(
<ComponentEditorDialog
text="name: test-component"
onClose={onCloseMock}
onComponentSaved={onComponentSavedMock}
renderSaveActions={renderSaveActions}
/>,
);

await waitFor(() => {
expect(
screen.getByRole("button", { name: /Save/i }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
fireEvent.click(await screen.findByRole("button", { name: "do-import" }));

await waitFor(() => {
expect(mockAddToComponentLibrary).toHaveBeenCalledWith(
mockHydratedComponent,
"editor_save",
);
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
expect(onComponentSavedMock).not.toHaveBeenCalled();
});

test("renderSaveActions: Back returns to the editor without applying", async () => {
const onCloseMock = vi.fn();
const onComponentSavedMock = vi.fn();
const renderSaveActions = renderActionsWith("do-update", "update");
vi.mocked(hydrateComponentReference).mockResolvedValue({
spec: { implementation: { container: { image: "test" } } },
name: "test-component",
digest: "abc123",
text: "name: test-component",
});

renderWithProviders(
<ComponentEditorDialog
text="name: test-component"
onClose={onCloseMock}
onComponentSaved={onComponentSavedMock}
renderSaveActions={renderSaveActions}
/>,
);

fireEvent.click(await screen.findByRole("button", { name: /Save/i }));
// Now in the actions view; go Back.
fireEvent.click(await screen.findByRole("button", { name: /Back/i }));

// Editor is shown again; nothing applied or closed.
await waitFor(() => {
expect(
screen.getByRole("button", { name: /Save/i }),
).toBeInTheDocument();
});
expect(onComponentSavedMock).not.toHaveBeenCalled();
expect(onCloseMock).not.toHaveBeenCalled();
expect(mockAddToComponentLibrary).not.toHaveBeenCalled();
});
});
});
Loading
Loading