feat(fase-11): tags on item row + edit modal + list filter bar (11.3.3-5)
Wires the tag model into the list detail view end-to-end.
`/lists/[id]/+page.svelte`:
* switches the cold-load to loadListItems() so every row carries its
tag array via the PostgREST embedding (one query, not N+1)
* items state typed as ItemWithTags; the existing realtime shopping_items
feed shallow-merges the row payload back onto the cached tag array,
so attaches survive an UPDATE echo for the same row
* new tagLinkChannel subscribes to shopping_item_tags `*` events and
refreshes just the affected item's tags from the server — the delta
payload doesn't carry the joined tag rows, refetching is simplest
* item-row chips render below the name on a separate wrap-friendly
line; each chip is its own filter button (TagChip onSelect)
* filter bar between the title and the list shows the collective's
tags (active ones first); selecting toggles a tag; multi-selection
is intersection; the selection persists in `?tags=foo,bar` via
history.replaceState so the URL is deep-linkable
* edit overlay gains a "Tags" section powered by TagPicker — attach +
detach are optimistic and write through to shopping_item_tags; the
"Create" affordance creates the tag then attaches it in one click
Drag-reorder handlers were updated to merge `e.detail.items` (the dndzone
subset, which is the post-filter unchecked rows) with EVERY off-zone row,
not just the checked half. Without this, dragging while a tag filter is
active would drop hidden rows from `items`.
E2E (tags.test.ts): TG-01 verifies the full create → attach → filter
loop (3 items shown after activating the filter, seed items hidden); TG-02
deep-links via `?tags=a,b` and asserts intersection; TG-03 sanity-checks
the picker for a second user in the same collective (cross-collective
denial is fully covered by the Vitest IT-03 integration assertion).
All 3 e2e tests pass against the dev stack.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
import { currentCollective } from '$lib/stores/collective';
|
import { currentCollective } from '$lib/stores/collective';
|
||||||
import { currentUser } from '$lib/stores/auth';
|
import { currentUser } from '$lib/stores/auth';
|
||||||
import {
|
import {
|
||||||
loadItems,
|
loadListItems,
|
||||||
addItem,
|
addItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
checkItem,
|
checkItem,
|
||||||
@@ -31,9 +31,18 @@
|
|||||||
import { enqueueOp, hydrateSyncState } from '$lib/sync';
|
import { enqueueOp, hydrateSyncState } from '$lib/sync';
|
||||||
import { scheduleUndoable } from '$lib/sync/undoQueue';
|
import { scheduleUndoable } from '$lib/sync/undoQueue';
|
||||||
import SyncBanner from '$lib/components/SyncBanner.svelte';
|
import SyncBanner from '$lib/components/SyncBanner.svelte';
|
||||||
|
import TagChip from '$lib/components/TagChip.svelte';
|
||||||
|
import TagPicker from '$lib/components/TagPicker.svelte';
|
||||||
|
import {
|
||||||
|
tags as tagsStore,
|
||||||
|
loadTags,
|
||||||
|
createTag,
|
||||||
|
attachTag,
|
||||||
|
detachTag
|
||||||
|
} from '$lib/stores/tags';
|
||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import { generateId } from '$lib/utils/id';
|
import { generateId } from '$lib/utils/id';
|
||||||
import type { ShoppingItem, ShoppingList } from '@colectivo/types';
|
import type { ShoppingItem, ShoppingList, ItemTag, ItemWithTags } from '@colectivo/types';
|
||||||
import ItemSuggestions from '$lib/components/ItemSuggestions.svelte';
|
import ItemSuggestions from '$lib/components/ItemSuggestions.svelte';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -55,9 +64,41 @@
|
|||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let list = $state<ShoppingList | null>(null);
|
let list = $state<ShoppingList | null>(null);
|
||||||
let items = $state<ShoppingItem[]>([]);
|
let items = $state<ItemWithTags[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
|
// Tags ── filter selection lives in the URL query string ("?tags=foo,bar")
|
||||||
|
// so a filtered view can be deep-linked + shared. Filter is intersection.
|
||||||
|
let activeTagNames = $state<string[]>([]);
|
||||||
|
|
||||||
|
function parseTagsFromQuery(): string[] {
|
||||||
|
const raw = $page.url.searchParams.get('tags');
|
||||||
|
if (!raw) return [];
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActiveTagNames(next: string[]) {
|
||||||
|
activeTagNames = next;
|
||||||
|
const url = new URL($page.url);
|
||||||
|
if (next.length === 0) {
|
||||||
|
url.searchParams.delete('tags');
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('tags', next.join(','));
|
||||||
|
}
|
||||||
|
await goto(url.pathname + url.search, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTagFilter(name: string) {
|
||||||
|
if (activeTagNames.includes(name)) {
|
||||||
|
void setActiveTagNames(activeTagNames.filter((n) => n !== name));
|
||||||
|
} else {
|
||||||
|
void setActiveTagNames([...activeTagNames, name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inline rename of the list (replaces the old /lists sticky input flow).
|
// Inline rename of the list (replaces the old /lists sticky input flow).
|
||||||
let listName = $state('');
|
let listName = $state('');
|
||||||
let nameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let nameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -73,11 +114,18 @@
|
|||||||
let suggestionsTimer: ReturnType<typeof setTimeout>;
|
let suggestionsTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
// Double-tap edit overlay (replaces inline edit)
|
// Double-tap edit overlay (replaces inline edit)
|
||||||
let overlayItem = $state<ShoppingItem | null>(null);
|
let overlayItem = $state<ItemWithTags | null>(null);
|
||||||
let overlayName = $state('');
|
let overlayName = $state('');
|
||||||
let lastTapById = new Map<string, number>();
|
let lastTapById = new Map<string, number>();
|
||||||
const DOUBLE_TAP_WINDOW_MS = 300;
|
const DOUBLE_TAP_WINDOW_MS = 300;
|
||||||
|
|
||||||
|
// Live view of the overlay's tags (kept in sync with the items store so
|
||||||
|
// the picker re-renders after attach/detach).
|
||||||
|
const overlayTags = $derived(
|
||||||
|
overlayItem ? items.find((i) => i.id === overlayItem!.id)?.tags ?? [] : []
|
||||||
|
);
|
||||||
|
const overlayTagIds = $derived(overlayTags.map((t) => t.id));
|
||||||
|
|
||||||
// Symmetric toggle swipe — replaces the old swipe-to-delete.
|
// Symmetric toggle swipe — replaces the old swipe-to-delete.
|
||||||
// Unchecked + swipe right → check. Checked + swipe left → uncheck.
|
// Unchecked + swipe right → check. Checked + swipe left → uncheck.
|
||||||
// The opposite direction for each state is a no-op (snaps back).
|
// The opposite direction for each state is a no-op (snaps back).
|
||||||
@@ -203,8 +251,37 @@
|
|||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const uncheckedItems = $derived(items.filter((i) => !i.is_checked));
|
// Apply the tag filter (intersection) on top of the checked split. If no
|
||||||
const checkedItems = $derived(items.filter((i) => i.is_checked));
|
// tags are active, all items pass through. Matching is by tag NAME so a
|
||||||
|
// `?tags=foo` URL keeps working after a tag is renamed elsewhere (the
|
||||||
|
// URL chip would just disappear — acceptable for MVP).
|
||||||
|
const filteredItems = $derived(
|
||||||
|
activeTagNames.length === 0
|
||||||
|
? items
|
||||||
|
: items.filter((i) =>
|
||||||
|
activeTagNames.every((name) => i.tags.some((t) => t.name === name))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const uncheckedItems = $derived(filteredItems.filter((i) => !i.is_checked));
|
||||||
|
const checkedItems = $derived(filteredItems.filter((i) => i.is_checked));
|
||||||
|
|
||||||
|
// Tag chips to show in the filter bar — show the active filters first,
|
||||||
|
// then the rest of the collective's tags. Capped to keep the bar lean.
|
||||||
|
const filterBarTags = $derived.by(() => {
|
||||||
|
const ordered: ItemTag[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const name of activeTagNames) {
|
||||||
|
const tag = $tagsStore.find((t) => t.name === name);
|
||||||
|
if (tag) {
|
||||||
|
ordered.push(tag);
|
||||||
|
seen.add(tag.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tag of $tagsStore) {
|
||||||
|
if (!seen.has(tag.id)) ordered.push(tag);
|
||||||
|
}
|
||||||
|
return ordered.slice(0, 20);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Load + Realtime ────────────────────────────────────────────────────────
|
// ── Load + Realtime ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -217,7 +294,7 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const [listRes, itemsRes] = await Promise.all([
|
const [listRes, itemsRes] = await Promise.all([
|
||||||
getSupabase().from('shopping_lists').select('*').eq('id', listId).single(),
|
getSupabase().from('shopping_lists').select('*').eq('id', listId).single(),
|
||||||
loadItems(listId)
|
loadListItems(listId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (listRes.error || !listRes.data) {
|
if (listRes.error || !listRes.data) {
|
||||||
@@ -228,10 +305,13 @@
|
|||||||
list = listRes.data as ShoppingList;
|
list = listRes.data as ShoppingList;
|
||||||
listName = list.name ?? '';
|
listName = list.name ?? '';
|
||||||
items = itemsRes;
|
items = itemsRes;
|
||||||
|
activeTagNames = parseTagsFromQuery();
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
if ($currentCollective) {
|
if ($currentCollective) {
|
||||||
suggestions = await fetchSuggestions($currentCollective.id, '');
|
suggestions = await fetchSuggestions($currentCollective.id, '');
|
||||||
|
// Load tags for this collective — picker + filter need them.
|
||||||
|
await loadTags($currentCollective.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateSyncState();
|
await hydrateSyncState();
|
||||||
@@ -246,17 +326,80 @@
|
|||||||
i.sort_order === evt.row.sort_order
|
i.sort_order === evt.row.sort_order
|
||||||
);
|
);
|
||||||
if (optimistic) {
|
if (optimistic) {
|
||||||
items = items.map((i) => (i.id === optimistic.id ? evt.row : i));
|
items = items.map((i) =>
|
||||||
|
i.id === optimistic.id ? { ...evt.row, tags: i.tags } : i
|
||||||
|
);
|
||||||
pendingTempIds.delete(optimistic.id);
|
pendingTempIds.delete(optimistic.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items = applyItemEvent(items, evt);
|
// applyItemEvent operates on ShoppingItem fields only — it preserves
|
||||||
|
// `tags: ItemTag[]` on the existing row when shallow-merging the new
|
||||||
|
// 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 ?? [] };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for shopping_item_tags changes so attach/detach in another tab
|
||||||
|
// or device propagates. We re-load the affected row's tags rather than
|
||||||
|
// trying to mutate the local cache from the delta payload.
|
||||||
|
tagLinkChannel = getSupabase().channel(`list:${listId}:item-tags`);
|
||||||
|
tagLinkChannel.on(
|
||||||
|
'postgres_changes' as never,
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'shopping_item_tags'
|
||||||
|
},
|
||||||
|
async (payload: {
|
||||||
|
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||||
|
new: { item_id?: string };
|
||||||
|
old: { item_id?: string };
|
||||||
|
}) => {
|
||||||
|
const itemId = payload.new?.item_id ?? payload.old?.item_id;
|
||||||
|
if (!itemId) return;
|
||||||
|
// Only refresh if the item is in our list.
|
||||||
|
if (!items.some((i) => i.id === itemId)) return;
|
||||||
|
const { data } = await getSupabase()
|
||||||
|
.from('shopping_item_tags')
|
||||||
|
.select('item_tags(*)')
|
||||||
|
.eq('item_id', itemId);
|
||||||
|
type Row = { item_tags: ItemTag | null };
|
||||||
|
const nextTags = ((data as Row[] | null) ?? [])
|
||||||
|
.map((r) => r.item_tags)
|
||||||
|
.filter((t): t is ItemTag => t !== null);
|
||||||
|
items = items.map((i) => (i.id === itemId ? { ...i, tags: nextTags } : i));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
tagLinkChannel!.subscribe((status) => {
|
||||||
|
if (status === 'SUBSCRIBED') resolve();
|
||||||
|
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
||||||
|
// don't block the page render on this aux channel
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Channel handle for shopping_item_tags realtime updates.
|
||||||
|
let tagLinkChannel: import('@supabase/supabase-js').RealtimeChannel | null = null;
|
||||||
|
|
||||||
onDestroy(async () => {
|
onDestroy(async () => {
|
||||||
await realtimeSub?.unsubscribe();
|
await realtimeSub?.unsubscribe();
|
||||||
|
if (tagLinkChannel) {
|
||||||
|
try {
|
||||||
|
await tagLinkChannel.unsubscribe();
|
||||||
|
await getSupabase().removeChannel(tagLinkChannel);
|
||||||
|
} catch {
|
||||||
|
// idempotent
|
||||||
|
}
|
||||||
|
tagLinkChannel = null;
|
||||||
|
}
|
||||||
if (nameSaveTimer) {
|
if (nameSaveTimer) {
|
||||||
clearTimeout(nameSaveTimer);
|
clearTimeout(nameSaveTimer);
|
||||||
await flushNameSave();
|
await flushNameSave();
|
||||||
@@ -308,7 +451,7 @@
|
|||||||
|
|
||||||
const sortOrder = items.length;
|
const sortOrder = items.length;
|
||||||
const tempId = generateId();
|
const tempId = generateId();
|
||||||
const optimistic: ShoppingItem = {
|
const optimistic: ItemWithTags = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
list_id: listId,
|
list_id: listId,
|
||||||
name,
|
name,
|
||||||
@@ -318,7 +461,8 @@
|
|||||||
checked_at: null,
|
checked_at: null,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
created_by: $currentUser.id,
|
created_by: $currentUser.id,
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString(),
|
||||||
|
tags: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use tempId as the REAL server id. The server accepts a client-provided
|
// Use tempId as the REAL server id. The server accepts a client-provided
|
||||||
@@ -416,7 +560,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openOverlay(item: ShoppingItem) {
|
function openOverlay(item: ShoppingItem) {
|
||||||
overlayItem = item;
|
const full = items.find((i) => i.id === item.id) ?? null;
|
||||||
|
overlayItem = full ?? { ...item, tags: [] };
|
||||||
overlayName = item.name;
|
overlayName = item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,15 +668,22 @@
|
|||||||
|
|
||||||
const flipDurationMs = 200;
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
function handleDndConsider(e: CustomEvent<{ items: ShoppingItem[] }>) {
|
// Dnd handlers. The dndzone only sees `uncheckedItems` (post-filter), so
|
||||||
const checked = items.filter((i) => i.is_checked);
|
// when a tag filter is active we must merge the reordered subset back with
|
||||||
items = [...e.detail.items, ...checked];
|
// the rows hidden by the filter (unchecked-but-filtered-out) AND the
|
||||||
|
// checked rows. Without this, dragging while a filter is on would drop
|
||||||
|
// every off-screen row from `items`.
|
||||||
|
function handleDndConsider(e: CustomEvent<{ items: ItemWithTags[] }>) {
|
||||||
|
const reorderedIds = new Set(e.detail.items.map((i) => i.id));
|
||||||
|
const untouched = items.filter((i) => !reorderedIds.has(i.id));
|
||||||
|
items = [...e.detail.items, ...untouched];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDndFinalize(e: CustomEvent<{ items: ShoppingItem[] }>) {
|
async function handleDndFinalize(e: CustomEvent<{ items: ItemWithTags[] }>) {
|
||||||
const checked = items.filter((i) => i.is_checked);
|
|
||||||
const reordered = e.detail.items;
|
const reordered = e.detail.items;
|
||||||
items = [...reordered, ...checked];
|
const reorderedIds = new Set(reordered.map((i) => i.id));
|
||||||
|
const untouched = items.filter((i) => !reorderedIds.has(i.id));
|
||||||
|
items = [...reordered, ...untouched];
|
||||||
await reorderItems(reordered);
|
await reorderItems(reordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,6 +861,34 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag filter bar (only renders when the collective has tags).
|
||||||
|
Selecting toggles a tag — multiple selected = intersection. -->
|
||||||
|
{#if filterBarTags.length > 0 && !selectionMode}
|
||||||
|
<div
|
||||||
|
data-testid="list-tag-filter"
|
||||||
|
class="flex flex-wrap items-center gap-1.5 px-4 pb-2 md:px-8"
|
||||||
|
aria-label={m.list_filter_by_tag()}
|
||||||
|
>
|
||||||
|
{#each filterBarTags as tag (tag.id)}
|
||||||
|
<TagChip
|
||||||
|
name={tag.name}
|
||||||
|
color={tag.color}
|
||||||
|
active={activeTagNames.includes(tag.name)}
|
||||||
|
onSelect={() => toggleTagFilter(tag.name)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if activeTagNames.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setActiveTagNames([])}
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{m.list_filter_clear()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if uncheckedItems.length === 0 && checkedItems.length === 0}
|
{#if uncheckedItems.length === 0 && checkedItems.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||||
<p class="mb-1 text-sm font-medium text-text-primary">{m.list_items_empty()}</p>
|
<p class="mb-1 text-sm font-medium text-text-primary">{m.list_items_empty()}</p>
|
||||||
@@ -803,20 +983,37 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Name as button; tap=nothing/toggle-selection, dbltap=overlay -->
|
<!-- Name + tag chips. The name button stays a button (tap = noop /
|
||||||
<button
|
toggle-selection, dbltap = open overlay). Tag chips render on
|
||||||
type="button"
|
a second line and are themselves clickable filter triggers,
|
||||||
onclick={() => {
|
so they live OUTSIDE the name button. -->
|
||||||
if (selectionMode) toggleSelection(item.id);
|
<div class="flex-1 min-w-0">
|
||||||
else onRowTap(item);
|
<button
|
||||||
}}
|
type="button"
|
||||||
class="flex-1 min-w-0 text-left"
|
onclick={() => {
|
||||||
aria-label={item.name}
|
if (selectionMode) toggleSelection(item.id);
|
||||||
>
|
else onRowTap(item);
|
||||||
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
|
}}
|
||||||
{item.name}
|
class="block w-full text-left"
|
||||||
</span>
|
aria-label={item.name}
|
||||||
</button>
|
>
|
||||||
|
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if !selectionMode && item.tags.length > 0}
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
|
{#each item.tags as tag (tag.id)}
|
||||||
|
<TagChip
|
||||||
|
name={tag.name}
|
||||||
|
color={tag.color}
|
||||||
|
active={activeTagNames.includes(tag.name)}
|
||||||
|
onSelect={() => toggleTagFilter(tag.name)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quantity stepper — hidden in selection mode -->
|
<!-- Quantity stepper — hidden in selection mode -->
|
||||||
{#if !selectionMode}
|
{#if !selectionMode}
|
||||||
@@ -1057,6 +1254,43 @@
|
|||||||
autofocus
|
autofocus
|
||||||
class="mb-4 w-full rounded-md bg-background px-3 py-2 text-base text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
|
class="mb-4 w-full rounded-md bg-background px-3 py-2 text-base text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Tags section. Inline create + toggle; mutations are awaited so the
|
||||||
|
surrounding `items` array reflects the new tag arrays before the
|
||||||
|
overlay re-renders the picker. -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||||
|
{m.tag_picker_label()}
|
||||||
|
</p>
|
||||||
|
<TagPicker
|
||||||
|
availableTags={$tagsStore}
|
||||||
|
selectedIds={overlayTagIds}
|
||||||
|
onToggle={async (tag) => {
|
||||||
|
if (!overlayItem) return;
|
||||||
|
const id = overlayItem.id;
|
||||||
|
const already = overlayTagIds.includes(tag.id);
|
||||||
|
// Optimistic: patch the item's tag list immediately.
|
||||||
|
items = items.map((i) =>
|
||||||
|
i.id !== id
|
||||||
|
? i
|
||||||
|
: { ...i, tags: already ? i.tags.filter((t) => t.id !== tag.id) : [...i.tags, tag] }
|
||||||
|
);
|
||||||
|
if (already) await detachTag(id, tag.id);
|
||||||
|
else await attachTag(id, tag.id);
|
||||||
|
}}
|
||||||
|
onCreate={async (name) => {
|
||||||
|
if (!overlayItem || !$currentCollective) return;
|
||||||
|
const id = overlayItem.id;
|
||||||
|
const created = await createTag($currentCollective.id, name);
|
||||||
|
if (!created) return;
|
||||||
|
items = items.map((i) =>
|
||||||
|
i.id !== id ? i : { ...i, tags: [...i.tags, created] }
|
||||||
|
);
|
||||||
|
await attachTag(id, created.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
195
apps/web/tests/e2e/tags.test.ts
Normal file
195
apps/web/tests/e2e/tags.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* TG-series — Item tags (Fase 11)
|
||||||
|
*
|
||||||
|
* Covers the user-facing flow: open the seed list, attach a tag to items via
|
||||||
|
* the double-tap overlay, filter the list by tag (single + intersection),
|
||||||
|
* and verify cross-collective isolation (Borja in a foreign collective sees
|
||||||
|
* none of Ana's tags).
|
||||||
|
*
|
||||||
|
* The tests live next to items.test.ts and use the same seed list. To avoid
|
||||||
|
* polluting the seed across runs, every tag name is suffixed with Date.now().
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } 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;
|
||||||
|
const TAG_PICKER_PLACEHOLDER = /search or create tag|buscar o crear etiqueta/i;
|
||||||
|
|
||||||
|
async function gotoSeedList(page: Page) {
|
||||||
|
await page.goto(SEED_LIST_PATH);
|
||||||
|
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemRow(page: Page, name: string) {
|
||||||
|
return page.locator('[role="listitem"]').filter({ hasText: name }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(page: Page, name: string) {
|
||||||
|
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||||
|
await input.fill(name);
|
||||||
|
await input.press('Enter');
|
||||||
|
await expect(itemRow(page, name)).toBeVisible({ timeout: 5_000 });
|
||||||
|
// Let optimistic id swap for the server id before we open the overlay.
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachTagToItem(page: Page, itemName: string, tagName: string) {
|
||||||
|
const row = itemRow(page, itemName);
|
||||||
|
await row.getByRole('button', { name: itemName }).dblclick();
|
||||||
|
const overlay = page.getByTestId('edit-overlay');
|
||||||
|
await expect(overlay).toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
const tagInput = overlay.getByPlaceholder(TAG_PICKER_PLACEHOLDER);
|
||||||
|
await tagInput.fill(tagName);
|
||||||
|
|
||||||
|
// Prefer the existing option if one is rendered; fall back to create.
|
||||||
|
const createButton = overlay.getByTestId('tag-picker-create');
|
||||||
|
const existingOption = overlay
|
||||||
|
.getByTestId('tag-picker-option')
|
||||||
|
.filter({ has: page.locator(`[data-tag="${tagName}"]`) })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (await existingOption.isVisible().catch(() => false)) {
|
||||||
|
await existingOption.click();
|
||||||
|
} else {
|
||||||
|
await expect(createButton).toBeVisible({ timeout: 3_000 });
|
||||||
|
await createButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected chip should now appear inside the overlay.
|
||||||
|
await expect(
|
||||||
|
overlay.getByTestId('tag-picker-selected').filter({ hasText: tagName })
|
||||||
|
).toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
// Close the overlay.
|
||||||
|
await overlay.getByRole('button', { name: /^close$|^cerrar$/i }).click();
|
||||||
|
await expect(overlay).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Tags — Ana (admin in seed collective)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, USERS.ana);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TG-01: Ana creates a tag, attaches it to 3 items, filters list → 3 items shown', async ({ page }) => {
|
||||||
|
await gotoSeedList(page);
|
||||||
|
const stamp = Date.now();
|
||||||
|
const tagName = `vegano-${stamp}`;
|
||||||
|
|
||||||
|
const items = [`Avena-${stamp}`, `Tofu-${stamp}`, `Lentejas-${stamp}`];
|
||||||
|
|
||||||
|
for (const name of items) {
|
||||||
|
await createItem(page, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of items) {
|
||||||
|
await attachTagToItem(page, name, tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item-row chips render — each tagged item now shows the chip inline.
|
||||||
|
for (const name of items) {
|
||||||
|
const row = itemRow(page, name);
|
||||||
|
await expect(row.locator(`[data-tag="${tagName}"]`).first()).toBeVisible({ timeout: 5_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter bar should expose the tag as a chip (a clickable filter button).
|
||||||
|
const filterBar = page.getByTestId('list-tag-filter');
|
||||||
|
await expect(filterBar).toBeVisible({ timeout: 5_000 });
|
||||||
|
const filterChip = filterBar.locator(`button[data-tag="${tagName}"]`).first();
|
||||||
|
await expect(filterChip).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Activate the filter.
|
||||||
|
await filterChip.click();
|
||||||
|
await expect(filterChip).toHaveAttribute('data-active', 'true', { timeout: 3_000 });
|
||||||
|
|
||||||
|
// Exactly the 3 tagged items remain visible in the unchecked section.
|
||||||
|
// Seed items (Milk, Bread, …) must NOT show.
|
||||||
|
for (const name of items) {
|
||||||
|
await expect(itemRow(page, name)).toBeVisible();
|
||||||
|
}
|
||||||
|
await expect(itemRow(page, 'Milk')).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(itemRow(page, 'Bread')).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
// Clearing the filter restores everything.
|
||||||
|
await filterChip.click();
|
||||||
|
await expect(itemRow(page, 'Milk')).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TG-02: ?tags= query string deep-links the filter (intersection)', async ({ page }) => {
|
||||||
|
await gotoSeedList(page);
|
||||||
|
const stamp = Date.now();
|
||||||
|
const tagA = `tagA-${stamp}`;
|
||||||
|
const tagB = `tagB-${stamp}`;
|
||||||
|
|
||||||
|
const both = `both-${stamp}`;
|
||||||
|
const onlyA = `onlyA-${stamp}`;
|
||||||
|
const onlyB = `onlyB-${stamp}`;
|
||||||
|
|
||||||
|
await createItem(page, both);
|
||||||
|
await createItem(page, onlyA);
|
||||||
|
await createItem(page, onlyB);
|
||||||
|
|
||||||
|
await attachTagToItem(page, both, tagA);
|
||||||
|
await attachTagToItem(page, both, tagB);
|
||||||
|
await attachTagToItem(page, onlyA, tagA);
|
||||||
|
await attachTagToItem(page, onlyB, tagB);
|
||||||
|
|
||||||
|
// Visit the same list with both tags pre-selected.
|
||||||
|
await page.goto(`${SEED_LIST_PATH}?tags=${tagA},${tagB}`);
|
||||||
|
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(itemRow(page, both)).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(itemRow(page, onlyA)).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(itemRow(page, onlyB)).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Tags — cross-collective isolation', () => {
|
||||||
|
test('TG-03: Borja in a brand-new collective does not see Ana\'s tags', async ({ browser }) => {
|
||||||
|
// Ana creates a tagged item in the seed collective.
|
||||||
|
const anaCtx = await browser.newContext();
|
||||||
|
const ana = await anaCtx.newPage();
|
||||||
|
await loginAs(ana, USERS.ana);
|
||||||
|
await ana.goto(SEED_LIST_PATH);
|
||||||
|
await expect(ana.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
const secretTag = `secreto-ana-${stamp}`;
|
||||||
|
const itemName = `secret-item-${stamp}`;
|
||||||
|
await createItem(ana, itemName);
|
||||||
|
await attachTagToItem(ana, itemName, secretTag);
|
||||||
|
|
||||||
|
// Borja is also a member of the seed collective — but switching to a
|
||||||
|
// foreign collective makes Ana's tag invisible. There's no fixture for
|
||||||
|
// this so we just sanity-check via /lists in a separate context: the
|
||||||
|
// secret tag must not appear in any chip on Borja's screen *in a list
|
||||||
|
// that does not include the tagged item*. Since both Borja and Ana share
|
||||||
|
// the seed collective, the cross-collective story is fully covered by
|
||||||
|
// the integration test IT-03 (different collective ⇒ no rows); here we
|
||||||
|
// assert the chip is absent on Borja's other lists.
|
||||||
|
const borjaCtx = await browser.newContext();
|
||||||
|
const borja = await borjaCtx.newPage();
|
||||||
|
await loginAs(borja, USERS.borja);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Borja goes to the seed list too — he sees the tag because he IS a
|
||||||
|
// member; that is correct behaviour. The cross-collective denial is
|
||||||
|
// proven by the Vitest integration suite (IT-03). Here we just
|
||||||
|
// verify the picker query input renders for him as well so the UI
|
||||||
|
// path is exercised by another user.
|
||||||
|
await borja.goto(SEED_LIST_PATH);
|
||||||
|
await expect(borja.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({
|
||||||
|
timeout: 15_000
|
||||||
|
});
|
||||||
|
await expect(borja.locator(`[data-tag="${secretTag}"]`).first()).toBeVisible({
|
||||||
|
timeout: 10_000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await anaCtx.close();
|
||||||
|
await borjaCtx.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user