feat(fase-11): realtime re-sort on sort_order UPDATE + drag-reorder e2e (11.4)
The drag handle + dndzone + reorderItems batch UPDATE were already wired
on the list detail view (Fase 5.10). One observable contract was missing:
when another client reorders an item, the local realtime UPDATE feed
shallow-merges the new payload onto the existing row but does NOT
re-position the row in the array — so the new order is invisible until
a reload. Fix: sort the `items` array by sort_order after every realtime
UPDATE / INSERT merge. Cheap (n is small per list) and matches the cold-
load order.
E2E (reorder-items.test.ts):
* RO-01: create 3 items, write a new sort_order via the same PATCH the
in-page reorderItems() helper does, reload, assert the reorder
persisted.
* RO-02: dual-context, Ana reorders, Borja sees the new order via
realtime within the suite's 10s window — without this commit's
sort fix the test failed because Borja's DOM order stayed at the
old order.
Drag emulation in headless Chromium is unreliable for svelte-dnd-action
(native HTML5 DnD). Both tests drive the same write path the page uses
(reorderItems = batch PATCH on sort_order) via fetch from inside the
browser context, using the session token the SDK has already stored.
The realtime + reload paths are unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -338,10 +338,16 @@
|
||||
// UPDATE payload. INSERTs of brand-new rows arrive without tags
|
||||
// (they're attached separately); seed an empty array.
|
||||
const next = applyItemEvent(items as unknown as ShoppingItem[], evt);
|
||||
items = next.map((row) => {
|
||||
const existing = items.find((i) => i.id === row.id);
|
||||
return { ...row, tags: existing?.tags ?? [] };
|
||||
});
|
||||
// Re-sort by sort_order so a realtime UPDATE that only changes the
|
||||
// row's sort_order reorders the rendered list without a refresh
|
||||
// (Fase 11.4.3). Without this the array order is stable across
|
||||
// shallow-merge and a remote reorder is invisible to other clients.
|
||||
items = next
|
||||
.map((row) => {
|
||||
const existing = items.find((i) => i.id === row.id);
|
||||
return { ...row, tags: existing?.tags ?? [] };
|
||||
})
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
});
|
||||
|
||||
// Listen for shopping_item_tags changes so attach/detach in another tab
|
||||
|
||||
204
apps/web/tests/e2e/reorder-items.test.ts
Normal file
204
apps/web/tests/e2e/reorder-items.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* RO-series — Drag-reorder of shopping items (Fase 11.4)
|
||||
*
|
||||
* The list detail view wires `svelte-dnd-action` over the unchecked-items
|
||||
* section via a per-row GripVertical handle, calling reorderItems() on
|
||||
* drop. These tests verify the user-observable contract:
|
||||
*
|
||||
* RO-01: a reorder persists across reload (sort_order is written to DB)
|
||||
* RO-02: Ana's reorder propagates to Borja via realtime UPDATEs on
|
||||
* shopping_items.sort_order without a refresh
|
||||
*
|
||||
* Note on drag emulation: svelte-dnd-action uses native HTML5 DnD which
|
||||
* headless Chromium handles poorly. Rather than fight the engine, both
|
||||
* tests drive the same write path the page uses (reorderItems batch
|
||||
* UPDATE) by issuing the PATCH calls directly against the REST endpoint
|
||||
* using the session token already in localStorage. The realtime
|
||||
* propagation, the SELECT after reload, and the optimistic UI update
|
||||
* paths are unchanged.
|
||||
*/
|
||||
import { test, expect, type Page, type Browser } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
const SEED_LIST_PATH = `/lists/${SEED_LIST_ID}`;
|
||||
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
|
||||
|
||||
// Dev-stack anon key (also in root .env). Playwright doesn't load .env, and
|
||||
// even when it did the LAN-IP supabase URL in there is wrong for headless.
|
||||
// This is the public anon key for the dev demo instance — no secrets here.
|
||||
const DEV_SUPABASE_URL = 'http://localhost:8001';
|
||||
const DEV_SUPABASE_ANON_KEY =
|
||||
process.env.PUBLIC_SUPABASE_ANON_KEY ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjIwOTE0OTEyOTZ9.fK2is86W1xqCPR525AOU_VukQL4CMxncX55ZBLab-dA';
|
||||
|
||||
async function gotoSeedList(page: Page) {
|
||||
await page.goto(SEED_LIST_PATH);
|
||||
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createItem(page: Page, name: string) {
|
||||
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||
await input.fill(name);
|
||||
await input.press('Enter');
|
||||
await expect(page.locator(`[role="listitem"][data-name="${name}"]`).first()).toBeVisible({
|
||||
timeout: 5_000
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
/** Read item rows in document order, filtered by prefix. */
|
||||
async function orderedNames(page: Page, prefix: string): Promise<string[]> {
|
||||
return page.evaluate((p) => {
|
||||
return Array.from(document.querySelectorAll('[role="listitem"][data-checked="false"]'))
|
||||
.map((el) => (el as HTMLElement).dataset.name ?? '')
|
||||
.filter((n) => n.startsWith(p));
|
||||
}, prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder items inside the browser context by PATCHing sort_order directly
|
||||
* to the Supabase REST endpoint via the same access token the SDK uses.
|
||||
* This is what the in-page reorderItems() helper does under the hood
|
||||
* (Promise.all of per-row updates).
|
||||
*/
|
||||
async function reorderByName(page: Page, listId: string, orderedNames: string[]) {
|
||||
await page.evaluate(
|
||||
async ({ listId, orderedNames, supabaseUrl, anonKey }) => {
|
||||
// Find the GoTrue session token written by supabase-js v2.
|
||||
let token = '';
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)!;
|
||||
if (k.startsWith('sb-') && k.endsWith('-auth-token')) {
|
||||
try {
|
||||
const obj = JSON.parse(localStorage.getItem(k)!);
|
||||
token = obj.access_token ?? obj.currentSession?.access_token ?? '';
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const listRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shopping_items?list_id=eq.${listId}&select=id,name`,
|
||||
{ headers }
|
||||
);
|
||||
const text = await listRes.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`reorderByName: bad JSON from ${supabaseUrl}/rest/v1/shopping_items: ${text.slice(0, 120)}`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`reorderByName: expected array, got ${JSON.stringify(parsed).slice(0, 120)} (status ${listRes.status})`
|
||||
);
|
||||
}
|
||||
const items = parsed as { id: string; name: string }[];
|
||||
const ids = orderedNames
|
||||
.map((n) => items.find((i) => i.name === n)?.id)
|
||||
.filter((x): x is string => !!x);
|
||||
|
||||
await Promise.all(
|
||||
ids.map((id, idx) =>
|
||||
fetch(`${supabaseUrl}/rest/v1/shopping_items?id=eq.${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, Prefer: 'return=minimal' },
|
||||
body: JSON.stringify({ sort_order: idx })
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
listId,
|
||||
orderedNames,
|
||||
supabaseUrl: DEV_SUPABASE_URL,
|
||||
anonKey: DEV_SUPABASE_ANON_KEY
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Drag-reorder', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
});
|
||||
|
||||
test('RO-01: reordering persists across reload', async ({ page }) => {
|
||||
await gotoSeedList(page);
|
||||
const stamp = Date.now();
|
||||
const prefix = `RO01-${stamp}-`;
|
||||
const names = [`${prefix}A`, `${prefix}B`, `${prefix}C`];
|
||||
|
||||
for (const n of names) {
|
||||
await createItem(page, n);
|
||||
}
|
||||
|
||||
const initial = await orderedNames(page, prefix);
|
||||
expect(initial).toEqual(names);
|
||||
|
||||
// Move C above A and B.
|
||||
await reorderByName(page, SEED_LIST_ID, [names[2], names[0], names[1]]);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const reordered = await orderedNames(page, prefix);
|
||||
expect(reordered[0]).toBe(names[2]);
|
||||
const aIdx = reordered.indexOf(names[0]);
|
||||
const bIdx = reordered.indexOf(names[1]);
|
||||
expect(aIdx).toBeGreaterThan(0);
|
||||
expect(bIdx).toBeGreaterThan(aIdx);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Drag-reorder — realtime', () => {
|
||||
async function loggedInListPage(browser: Browser, user: (typeof USERS)[keyof typeof USERS]) {
|
||||
const ctx = await browser.newContext();
|
||||
const p = await ctx.newPage();
|
||||
await loginAs(p, user);
|
||||
await p.goto(SEED_LIST_PATH);
|
||||
await expect(p.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
return { ctx, p };
|
||||
}
|
||||
|
||||
test('RO-02: a reorder by Ana propagates to Borja without reload', async ({ browser }) => {
|
||||
const ana = await loggedInListPage(browser, USERS.ana);
|
||||
const borja = await loggedInListPage(browser, USERS.borja);
|
||||
|
||||
try {
|
||||
const stamp = Date.now();
|
||||
const prefix = `RO02-${stamp}-`;
|
||||
const names = [`${prefix}X`, `${prefix}Y`];
|
||||
for (const n of names) {
|
||||
await createItem(ana.p, n);
|
||||
}
|
||||
|
||||
// Both sides see the items.
|
||||
for (const n of names) {
|
||||
await expect(
|
||||
borja.p.locator(`[role="listitem"][data-name="${n}"]`).first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Reverse the order on Ana's side.
|
||||
await reorderByName(ana.p, SEED_LIST_ID, [names[1], names[0]]);
|
||||
|
||||
// Realtime feeds the new order to Borja within the suite's 10s window.
|
||||
await expect
|
||||
.poll(
|
||||
async () => orderedNames(borja.p, prefix),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual([names[1], names[0]]);
|
||||
} finally {
|
||||
await ana.ctx.close();
|
||||
await borja.ctx.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user