mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add drag-to-reorder for community rail (#2549)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
d0ab3fdb05
commit
1e68c6c050
@@ -71,7 +71,7 @@ export default defineConfig({
|
||||
"**/home-collapsed-top-chrome.spec.ts",
|
||||
"**/top-chrome-zoom-clearance.spec.ts",
|
||||
"**/thread-unread.spec.ts",
|
||||
"**/workspace-rail.spec.ts",
|
||||
"**/community-rail.spec.ts",
|
||||
"**/boot-splash.spec.ts",
|
||||
"**/thread-reply-anchor-roleplay.spec.ts",
|
||||
"**/threadpane-ultrawide.spec.ts",
|
||||
|
||||
@@ -714,6 +714,7 @@ export function AppShell() {
|
||||
}
|
||||
onAddCommunity={addCommunityDialog.openDialog}
|
||||
onRemoveCommunity={communitiesHook.removeCommunity}
|
||||
onReorderCommunities={communitiesHook.reorderCommunities}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
communities={communitiesHook.communities}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Unit tests for applyCommunitiesOrder — the pure permutation helper that
|
||||
* drives community-rail drag-to-reorder.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { applyCommunitiesOrder } from "./useCommunities.tsx";
|
||||
|
||||
const A = {
|
||||
id: "ws-a",
|
||||
name: "Alpha",
|
||||
relayUrl: "wss://a.example.com",
|
||||
addedAt: "2024-01-01",
|
||||
};
|
||||
const B = {
|
||||
id: "ws-b",
|
||||
name: "Bravo",
|
||||
relayUrl: "wss://b.example.com",
|
||||
addedAt: "2024-01-02",
|
||||
};
|
||||
const C = {
|
||||
id: "ws-c",
|
||||
name: "Charlie",
|
||||
relayUrl: "wss://c.example.com",
|
||||
addedAt: "2024-01-03",
|
||||
};
|
||||
|
||||
describe("applyCommunitiesOrder", () => {
|
||||
it("reorders communities to match orderedIds", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-a", "ws-b"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-c", "ws-a", "ws-b"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns same order when orderedIds matches current order", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-a", "ws-b", "ws-c"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("appends communities not mentioned in orderedIds at the end in original relative order", () => {
|
||||
// C was added after drag — not in orderedIds — should tail-append
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles orderedIds that contain stale IDs not present in communities", () => {
|
||||
// "ws-gone" is a stale id — silent skip, no crash
|
||||
const result = applyCommunitiesOrder([A, B], ["ws-b", "ws-gone", "ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the full list when orderedIds is empty — original order preserved", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], []);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a single-element list (no-op reorder)", () => {
|
||||
const result = applyCommunitiesOrder([A], ["ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles an empty communities list", () => {
|
||||
const result = applyCommunitiesOrder([], ["ws-a", "ws-b"]);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("does not mutate the original array", () => {
|
||||
const original = [A, B, C];
|
||||
applyCommunitiesOrder(original, ["ws-c", "ws-a", "ws-b"]);
|
||||
assert.deepEqual(
|
||||
original.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves object identity of each community (no clone)", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-b", "ws-a"]);
|
||||
assert.equal(result[0], C);
|
||||
assert.equal(result[1], B);
|
||||
assert.equal(result[2], A);
|
||||
});
|
||||
|
||||
it("handles duplicate IDs in orderedIds — first occurrence wins", () => {
|
||||
// Defensive: dnd-kit should never produce duplicates, but guard anyway.
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-b", "ws-a"]);
|
||||
// ws-b appears once, ws-a once, ws-c appended
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a", "ws-c"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,42 @@ export function resolveCommunityUpdateResult(
|
||||
return { kind: "updated", requiresReinit: backendFieldsChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permute `communities` so that its order matches `orderedIds`.
|
||||
*
|
||||
* - Communities whose id appears in `orderedIds` are placed first, in the
|
||||
* order given by `orderedIds`.
|
||||
* - Communities not mentioned in `orderedIds` (e.g. added after the drag
|
||||
* completed) are appended at the end in their original relative order.
|
||||
*
|
||||
* Pure and side-effect-free — extracted so it can be unit-tested without
|
||||
* a DOM or React.
|
||||
*/
|
||||
export function applyCommunitiesOrder(
|
||||
communities: Community[],
|
||||
orderedIds: string[],
|
||||
): Community[] {
|
||||
const byId = new Map(communities.map((c) => [c.id, c]));
|
||||
const seen = new Set<string>();
|
||||
const reordered: Community[] = [];
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c && !seen.has(id)) {
|
||||
reordered.push(c);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of communities) {
|
||||
if (!seen.has(c.id)) {
|
||||
reordered.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
export type UseCommunitiesReturn = {
|
||||
communities: Community[];
|
||||
activeCommunity: Community | null;
|
||||
@@ -90,6 +126,8 @@ export type UseCommunitiesReturn = {
|
||||
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
|
||||
>,
|
||||
) => UpdateCommunityResult;
|
||||
/** Persist a new display order for the rail. IDs not in orderedIds keep their relative position at the end. */
|
||||
reorderCommunities: (orderedIds: string[]) => void;
|
||||
};
|
||||
|
||||
const CommunitiesContext = createContext<UseCommunitiesReturn | null>(null);
|
||||
@@ -242,6 +280,14 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const reorderCommunities = useCallback((orderedIds: string[]) => {
|
||||
setCommunitiesState((prev) => {
|
||||
const next = applyCommunitiesOrder(prev, orderedIds);
|
||||
saveCommunities(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
communities,
|
||||
activeCommunity,
|
||||
@@ -252,5 +298,6 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
switchCommunity,
|
||||
reconnectCommunity,
|
||||
updateCommunity,
|
||||
reorderCommunities,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -33,6 +50,7 @@ type CommunityRailProps = {
|
||||
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemoveCommunity: (id: string) => void;
|
||||
onReorderCommunities: (orderedIds: string[]) => void;
|
||||
};
|
||||
|
||||
const MAX_BADGE = 99;
|
||||
@@ -76,6 +94,9 @@ function CommunityButton({
|
||||
iconUrl,
|
||||
onSwitch,
|
||||
menu,
|
||||
dragListeners,
|
||||
dragAttributes,
|
||||
isDragging,
|
||||
}: {
|
||||
community: Community;
|
||||
isActive: boolean;
|
||||
@@ -83,6 +104,9 @@ function CommunityButton({
|
||||
iconUrl: string | null;
|
||||
onSwitch: () => void;
|
||||
menu: React.ReactNode;
|
||||
dragListeners?: React.HTMLAttributes<HTMLElement>;
|
||||
dragAttributes?: React.HTMLAttributes<HTMLElement>;
|
||||
isDragging?: boolean;
|
||||
}) {
|
||||
const { mentionCount, showBadge, showDot, pending, badgeLabel } =
|
||||
communityRailIndicators(unread);
|
||||
@@ -101,10 +125,15 @@ function CommunityButton({
|
||||
<button
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
aria-label={tooltipLabel}
|
||||
className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center touch-none outline-hidden focus:outline-none focus-visible:outline-none",
|
||||
isDragging && "opacity-30",
|
||||
)}
|
||||
data-testid={`community-rail-button-${community.id}`}
|
||||
onClick={onSwitch}
|
||||
type="button"
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -154,6 +183,105 @@ function CommunityButton({
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityDragOverlay({
|
||||
community,
|
||||
iconUrl,
|
||||
}: {
|
||||
community: Community;
|
||||
iconUrl: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-9 w-9 cursor-grabbing items-center justify-center overflow-hidden rounded-xl bg-primary text-xs font-semibold text-primary-foreground opacity-90 shadow-lg ring-1 ring-sidebar-border"
|
||||
data-buzz-flat
|
||||
>
|
||||
{iconUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
src={iconUrl}
|
||||
/>
|
||||
) : (
|
||||
getInitials(community.name) || "🐝"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCommunityButton({
|
||||
community,
|
||||
activeCommunityId,
|
||||
iconsByCommunity,
|
||||
unreadByCommunity,
|
||||
onSwitchCommunity,
|
||||
onMarkAllRead,
|
||||
onSetEditingCommunity,
|
||||
}: {
|
||||
community: Community;
|
||||
activeCommunityId: string | null;
|
||||
iconsByCommunity: Record<string, string | null | undefined>;
|
||||
unreadByCommunity: Record<string, CommunityUnreadState>;
|
||||
onSwitchCommunity: (id: string) => void;
|
||||
onMarkAllRead: (community: Community) => void;
|
||||
onSetEditingCommunity: (community: Community) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: community.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<CommunityButton
|
||||
community={community}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={listeners}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
isActive={community.id === activeCommunityId}
|
||||
isDragging={isDragging}
|
||||
menu={
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onMarkAllRead(community)}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
void writeTextToClipboard(community.relayUrl);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Copy relay URL
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onSetEditingCommunity(community)}>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Community settings
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
}
|
||||
onSwitch={() => onSwitchCommunity(community.id)}
|
||||
unread={
|
||||
unreadByCommunity[community.id] ?? {
|
||||
hasUnread: false,
|
||||
state: "unknown",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord/Slack-style vertical rail of communities on the far left of the app.
|
||||
* Shows a mention-count badge for inactive communities (observed via
|
||||
@@ -169,6 +297,7 @@ export function CommunityRail({
|
||||
onAddCommunity,
|
||||
onUpdateCommunity,
|
||||
onRemoveCommunity,
|
||||
onReorderCommunities,
|
||||
}: CommunityRailProps) {
|
||||
const { unreadByCommunity, markCommunityRead } = useCommunityUnread(
|
||||
communities,
|
||||
@@ -179,10 +308,39 @@ export function CommunityRail({
|
||||
const { markAllChannelsRead } = useAppShell();
|
||||
const [editingCommunity, setEditingCommunity] =
|
||||
React.useState<Community | null>(null);
|
||||
const [draggingId, setDraggingId] = React.useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
if (communities.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const communityIds = communities.map((c) => c.id);
|
||||
const draggingCommunity = draggingId
|
||||
? (communities.find((c) => c.id === draggingId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setDraggingId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setDraggingId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = communityIds.indexOf(active.id as string);
|
||||
const newIdx = communityIds.indexOf(over.id as string);
|
||||
if (oldIdx !== -1 && newIdx !== -1) {
|
||||
onReorderCommunities(arrayMove(communityIds, oldIdx, newIdx));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = (community: Community) => {
|
||||
if (community.id === activeCommunityId) {
|
||||
markAllChannelsRead();
|
||||
@@ -211,42 +369,37 @@ export function CommunityRail({
|
||||
)}
|
||||
data-testid="community-rail"
|
||||
>
|
||||
{communities.map((community) => (
|
||||
<CommunityButton
|
||||
key={community.id}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
isActive={community.id === activeCommunityId}
|
||||
menu={
|
||||
<>
|
||||
<ContextMenuItem onClick={() => handleMarkAllRead(community)}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
void writeTextToClipboard(community.relayUrl);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Copy relay URL
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => setEditingCommunity(community)}>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Community settings
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
}
|
||||
onSwitch={() => onSwitchCommunity(community.id)}
|
||||
unread={
|
||||
unreadByCommunity[community.id] ?? {
|
||||
hasUnread: false,
|
||||
state: "unknown",
|
||||
}
|
||||
}
|
||||
community={community}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext
|
||||
items={communityIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{communities.map((community) => (
|
||||
<SortableCommunityButton
|
||||
key={community.id}
|
||||
activeCommunityId={activeCommunityId}
|
||||
community={community}
|
||||
iconsByCommunity={iconsByCommunity}
|
||||
unreadByCommunity={unreadByCommunity}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onSetEditingCommunity={setEditingCommunity}
|
||||
onSwitchCommunity={onSwitchCommunity}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{draggingCommunity ? (
|
||||
<CommunityDragOverlay
|
||||
community={draggingCommunity}
|
||||
iconUrl={iconsByCommunity[draggingCommunity.id] ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -216,4 +216,148 @@ test.describe("community rail", () => {
|
||||
expect(toggleBox).not.toBeNull();
|
||||
expect(toggleBox?.x ?? 0).toBeLessThan(120);
|
||||
});
|
||||
|
||||
test("drag-to-reorder updates the stored community order and survives reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
// Seed only if not already set so the persisted order survives page.reload().
|
||||
await page.addInitScript(
|
||||
({ list, active }) => {
|
||||
if (!window.localStorage.getItem("buzz-communities")) {
|
||||
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
|
||||
}
|
||||
if (!window.localStorage.getItem("buzz-active-community-id")) {
|
||||
window.localStorage.setItem("buzz-active-community-id", active);
|
||||
}
|
||||
},
|
||||
{ list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
||||
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
||||
await expect(buttonA).toBeVisible();
|
||||
await expect(buttonB).toBeVisible();
|
||||
|
||||
// Drag B (lower) up over A (higher) so the order becomes [B, A].
|
||||
const boxA = await buttonA.boundingBox();
|
||||
const boxB = await buttonB.boundingBox();
|
||||
if (!boxA || !boxB) throw new Error("community buttons not laid out");
|
||||
|
||||
const startX = boxB.x + boxB.width / 2;
|
||||
const startY = boxB.y + boxB.height / 2;
|
||||
const targetY = boxA.y + boxA.height / 2;
|
||||
|
||||
// dnd-kit PointerSensor requires a 6px activation distance before it picks
|
||||
// up the drag. Move in small steps so pointermove events fire on every pixel.
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX, startY - 3, { steps: 3 });
|
||||
await page.mouse.move(startX, targetY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
// The community list in localStorage must now be [B, A].
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("buzz-communities");
|
||||
if (!raw) return null;
|
||||
const list = JSON.parse(raw) as Array<{ id: string }>;
|
||||
return list.map((c) => c.id);
|
||||
}),
|
||||
)
|
||||
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
||||
|
||||
// Verify the new order is also reflected in the rendered DOM — B button
|
||||
// must appear above A button.
|
||||
const newBoxA = await buttonA.boundingBox();
|
||||
const newBoxB = await buttonB.boundingBox();
|
||||
if (!newBoxA || !newBoxB)
|
||||
throw new Error("community buttons not laid out after drag");
|
||||
expect(newBoxB.y).toBeLessThan(newBoxA.y);
|
||||
|
||||
// Reload and confirm the order survives restart: addInitScript is
|
||||
// conditional (no-op when data already exists), so the dragged [B, A]
|
||||
// order is what React reads on boot.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("community-rail")).toBeVisible();
|
||||
|
||||
// Storage must still be [B, A] after reload.
|
||||
const storedOrder = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("buzz-communities");
|
||||
if (!raw) return null;
|
||||
const list = JSON.parse(raw) as Array<{ id: string }>;
|
||||
return list.map((c) => c.id);
|
||||
});
|
||||
expect(storedOrder).toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
||||
|
||||
// DOM order must also be [B, A] after reload.
|
||||
const reloadBoxA = await buttonA.boundingBox();
|
||||
const reloadBoxB = await buttonB.boundingBox();
|
||||
if (!reloadBoxA || !reloadBoxB)
|
||||
throw new Error("community buttons not laid out after reload");
|
||||
expect(reloadBoxB.y).toBeLessThan(reloadBoxA.y);
|
||||
});
|
||||
|
||||
test("keyboard reorder: Space to pick up, ArrowUp to move, Space to drop updates stored order", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
|
||||
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
||||
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
||||
await expect(buttonA).toBeVisible();
|
||||
await expect(buttonB).toBeVisible();
|
||||
|
||||
// Focus B (the second/lower item) and use keyboard to move it above A.
|
||||
// Note: page.keyboard.press("Space") fires the button's native click on this
|
||||
// Chromium build even when React's onKeyDown calls preventDefault — a CDP
|
||||
// input-injection quirk. The synthetic dispatch below goes directly through
|
||||
// React's event system where preventDefault correctly suppresses the click,
|
||||
// while still exercising the real KeyboardSensor path (Thufir verified the
|
||||
// test fails when KeyboardSensor is removed).
|
||||
await buttonB.focus();
|
||||
await page.evaluate((testId) => {
|
||||
const el = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!el) throw new Error(`button not found: ${testId}`);
|
||||
el.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: " ",
|
||||
code: "Space",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, `community-rail-button-${COMMUNITY_B.id}`);
|
||||
// ArrowUp moves the active item one slot up.
|
||||
await page.keyboard.press("ArrowUp");
|
||||
// Space drops the item — same synthetic dispatch for consistency.
|
||||
await page.evaluate((testId) => {
|
||||
const el = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!el) throw new Error(`button not found: ${testId}`);
|
||||
el.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: " ",
|
||||
code: "Space",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, `community-rail-button-${COMMUNITY_B.id}`);
|
||||
|
||||
// The community list in localStorage must now be [B, A].
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("buzz-communities");
|
||||
if (!raw) return null;
|
||||
const list = JSON.parse(raw) as Array<{ id: string }>;
|
||||
return list.map((c) => c.id);
|
||||
}),
|
||||
)
|
||||
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user