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
34 changes: 6 additions & 28 deletions desktop/src/features/channels/ui/ChannelManagementSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,6 @@ export function ChannelManagementSheet({
const deleteChannelMutation = useDeleteChannelMutation(channelId);
const joinChannelMutation = useJoinChannelMutation(channelId);
const leaveChannelMutation = useLeaveChannelMutation(channelId);
const channelIdRef = React.useRef(channelId);
channelIdRef.current = channelId;

const detail = detailsQuery.data ?? channel;
const members = React.useMemo(() => {
Expand Down Expand Up @@ -167,8 +165,6 @@ export function ChannelManagementSheet({
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false);
const [isConvertingVisibility, setIsConvertingVisibility] =
React.useState(false);
const [hasUserEditedChannelDraft, setHasUserEditedChannelDraft] =
React.useState(false);
const [activeView, setActiveView] = React.useState<"summary" | "canvas">(
Expand Down Expand Up @@ -268,6 +264,7 @@ export function ChannelManagementSheet({
if (!next) {
setNameDraft(resolvedChannel.name);
setDescriptionDraft(resolvedChannel.description);
setIsPrivateDraft(currentVisibility === "private");
setIsEphemeralDraft(currentTtlSeconds !== null);
setTtlSecondsDraft(currentTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS);
setHasUserEditedChannelDraft(false);
Expand Down Expand Up @@ -298,25 +295,6 @@ export function ChannelManagementSheet({
}
}

async function handleConvertVisibility(visibility: "open" | "private") {
if (visibility === currentVisibility) {
return;
}
setIsConvertingVisibility(true);
try {
const updatedChannel = await updateChannelDetailsMutation.mutateAsync({
visibility,
});
if (channelIdRef.current === updatedChannel.id) {
setIsPrivateDraft(visibility === "private");
}
} catch {
// React Query stores mutation errors; keep the dialog open and render them.
} finally {
setIsConvertingVisibility(false);
}
}

return (
<DialogPrimitive.Root
modal={!isSplitLayout}
Expand Down Expand Up @@ -441,7 +419,7 @@ export function ChannelManagementSheet({
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>
Edit {currentVisibility === "private" ? "private" : "public"}{" "}
Edit {nextVisibility === "private" ? "private" : "public"}{" "}
channel
</DialogTitle>
</DialogHeader>
Expand Down Expand Up @@ -525,10 +503,10 @@ export function ChannelManagementSheet({
/>
<ChannelPermissionsSettings
disabled={isSavingChannelEdits}
isPending={isConvertingVisibility}
onVisibilityChange={(visibility) =>
void handleConvertVisibility(visibility)
}
onVisibilityChange={(visibility) => {
setIsPrivateDraft(visibility === "private");
setHasUserEditedChannelDraft(true);
}}
testIdPrefix="channel-management"
visibility={isPrivateDraft ? "private" : "open"}
/>
Expand Down
22 changes: 4 additions & 18 deletions desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChevronDown, LoaderCircle } from "lucide-react";
import { ChevronDown } from "lucide-react";

import type { ChannelVisibility } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
Expand All @@ -13,13 +13,11 @@ import { cn } from "@/shared/lib/cn";

export function ChannelPermissionsSettings({
disabled,
isPending = false,
onVisibilityChange,
testIdPrefix,
visibility,
}: {
disabled?: boolean;
isPending?: boolean;
onVisibilityChange: (visibility: ChannelVisibility) => void;
testIdPrefix: string;
visibility: ChannelVisibility;
Expand All @@ -38,29 +36,17 @@ export function ChannelPermissionsSettings({
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-busy={isPending}
aria-label={
isPending
? "Updating visibility"
: `Visibility: ${visibilityLabel}`
}
aria-label={`Visibility: ${visibilityLabel}`}
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid={`${testIdPrefix}-permissions`}
disabled={disabled}
type="button"
variant="ghost"
>
<span aria-live="polite" className="text-right">
{isPending ? "Updating…" : visibilityLabel}
{visibilityLabel}
</span>
{isPending ? (
<LoaderCircle
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground/70 motion-safe:animate-spin"
/>
) : (
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
)}
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
Expand Down
83 changes: 71 additions & 12 deletions desktop/tests/e2e/channel-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";

// `general` seeds the mock identity as owner, so the owner/admin-gated
// visibility + ephemeral controls are live and interactive.
// visibility + ephemeral controls are editable. Visibility is a deferred
// draft: selecting a value only updates local state; it persists on Save.
async function openManagementSheet(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
Expand Down Expand Up @@ -83,28 +84,42 @@ test.describe("channel controls", () => {
await settle(page);
});

test("02 — visibility updates immediately", async ({ page }) => {
test("02 — visibility defers to Save", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
await openEditDialog(page);

const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
const permissions = page.getByTestId("channel-management-permissions");

// Save starts disabled with no pending edits.
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();

// Selecting a visibility only updates the local draft: the dialog title
// reflects the pending choice and Save becomes enabled, but nothing is
// persisted yet (no "Updating…" state).
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(permissions).toHaveAttribute("aria-busy", "true");
await expect(permissions).toContainText("Updating…");
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Private");
await expect(permissions).not.toHaveAttribute("aria-busy", "true");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
).toBeEnabled();
// Wait for the dropdown to fully close before reopening it, so the
// trigger stays mounted/stable for the next interaction.
await expect(
page.getByTestId("channel-management-permissions-option-private"),
).toHaveCount(0);

// Toggling back to the original value clears the draft and disables Save.
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-open")
Expand All @@ -116,6 +131,36 @@ test.describe("channel controls", () => {
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await expect(
page.getByTestId("channel-management-permissions-option-open"),
).toHaveCount(0);

// Choose Private again and commit via Save.
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByTestId("channel-management-save-changes"),
).toHaveText("Saving...");
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);

// Reopen to confirm the visibility change persisted.
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await settle(page);
});

Expand Down Expand Up @@ -272,6 +317,13 @@ test.describe("channel controls", () => {
await page
.getByRole("textbox", { name: "Description" })
.fill("This description should be discarded");
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await selectTemporaryChannelType(page);
await expect(
page.getByTestId("channel-management-save-changes"),
Expand All @@ -291,6 +343,12 @@ test.describe("channel controls", () => {
await expect(
page.getByRole("textbox", { name: "Description" }),
).toHaveValue("General discussion for everyone");
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
Expand All @@ -302,7 +360,7 @@ test.describe("channel controls", () => {
).toBeDisabled();
});

test("10 — stale visibility updates do not affect a new channel", async ({
test("10 — unsaved visibility draft does not leak to a new channel", async ({
page,
}) => {
await installMockBridge(page, { updateChannelDelayMs: 1_500 });
Expand All @@ -314,7 +372,10 @@ test.describe("channel controls", () => {
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(permissions).toHaveAttribute("aria-busy", "true");
// Draft only — never saved. The dialog title reflects the pending choice.
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();

const agentsChannelId = await page
.getByTestId("channel-agents")
Expand All @@ -335,14 +396,12 @@ test.describe("channel controls", () => {
window.dispatchEvent(new PopStateEvent("popstate"));
}, agentsChannelId);
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();

await expect(permissions).toHaveAttribute("aria-busy", "false");
await expect(permissions).toHaveAccessibleName("Visibility: Public");
// The unsaved draft must not carry over: the new channel re-syncs from
// server state and shows its own (public) visibility.
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Public");
});
});
Loading