feat(fase-15): /collective/manage common items UI + e2e (15.3)
New subsection on /collective/manage:
- Table with rows of (name, uses, last used) + 3-way Hide/Normal/Boost
segmented control writing weight=-50/0/50, + Remove action that opens
a confirmation modal explaining "items already in lists are not
touched".
- Add common-item modal (admin only) — input + same 3-way control,
defaulting to Boost; useful for seeding the catalogue before lists
actually mention an item.
- Empty / filtered-empty states + client-side substring search.
- Members see the entire table with all buttons disabled and a tooltip
pointing to "only admins can manage common items". Guests don't see
the section at all.
Implementation notes:
- The data-weight-state attribute on each row gives the test suite a
stable handle for the current visual state (boost / normal / hide).
- The segmented control is a plain `inline-flex` of three buttons —
different visual language to the existing section-visibility toggles
(those are checkboxes), so there's no ambiguity at a glance.
- HIDE_WEIGHT / BOOST_WEIGHT constants make the magic numbers explicit;
the column itself has no check constraint so future tiers can land
without a schema change.
Playwright covers CI-01 (Ana boosts yogurt → it leads the dropdown ahead
of higher-use-count milk), CI-03 (Borja sees the table, all actions
disabled, "Add common item" hidden), CI-04 (Ana purges apples → row gone
from the table). CI-02 (exclude-on-list) lives in the same file but
exercises the /lists/[id] page and ships in the next commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -315,5 +315,29 @@
|
||||
"admin_server_section_state_unset": "No opinion",
|
||||
"admin_server_info": "Server info",
|
||||
"admin_error_forbidden": "You don't have access to this area.",
|
||||
"admin_error_unknown": "Something went wrong. Please try again."
|
||||
"admin_error_unknown": "Something went wrong. Please try again.",
|
||||
"common_items_title": "Common items",
|
||||
"common_items_blurb": "Promote items that always appear first in suggestions, or hide noise. Members see the catalogue but cannot edit it.",
|
||||
"common_items_search_placeholder": "Search items…",
|
||||
"common_items_empty": "No common items yet. Items added to lists will appear here.",
|
||||
"common_items_empty_filtered": "No items match your search.",
|
||||
"common_items_col_name": "Name",
|
||||
"common_items_col_weight": "Visibility",
|
||||
"common_items_col_uses": "Uses",
|
||||
"common_items_col_last_used": "Last used",
|
||||
"common_items_col_actions": "Actions",
|
||||
"common_items_state_hide": "Hide",
|
||||
"common_items_state_normal": "Normal",
|
||||
"common_items_state_boost": "Boost",
|
||||
"common_items_state_locked_tooltip": "Only admins can manage common items.",
|
||||
"common_items_purge_button": "Remove",
|
||||
"common_items_purge_modal_title": "Remove {name}?",
|
||||
"common_items_purge_modal_body": "This only removes the suggestion entry. Items already in lists are not affected.",
|
||||
"common_items_purge_confirm": "Remove",
|
||||
"common_items_add_button": "Add common item",
|
||||
"common_items_add_modal_title": "Add common item",
|
||||
"common_items_add_name_label": "Item name",
|
||||
"common_items_add_name_placeholder": "e.g. olive oil",
|
||||
"common_items_add_visibility_label": "Visibility",
|
||||
"common_items_add_save": "Add"
|
||||
}
|
||||
|
||||
@@ -315,5 +315,29 @@
|
||||
"admin_server_section_state_unset": "Sin opinión",
|
||||
"admin_server_info": "Información del servidor",
|
||||
"admin_error_forbidden": "No tienes acceso a esta zona.",
|
||||
"admin_error_unknown": "Algo ha ido mal. Inténtalo de nuevo."
|
||||
"admin_error_unknown": "Algo ha ido mal. Inténtalo de nuevo.",
|
||||
"common_items_title": "Items frecuentes",
|
||||
"common_items_blurb": "Promueve items que siempre aparezcan primero en las sugerencias, u oculta los que no quieras ver. Los miembros ven el catálogo pero no pueden editarlo.",
|
||||
"common_items_search_placeholder": "Buscar items…",
|
||||
"common_items_empty": "Aún no hay items frecuentes. Los items que se añadan a las listas aparecerán aquí.",
|
||||
"common_items_empty_filtered": "Ningún item coincide con tu búsqueda.",
|
||||
"common_items_col_name": "Nombre",
|
||||
"common_items_col_weight": "Visibilidad",
|
||||
"common_items_col_uses": "Usos",
|
||||
"common_items_col_last_used": "Último uso",
|
||||
"common_items_col_actions": "Acciones",
|
||||
"common_items_state_hide": "Ocultar",
|
||||
"common_items_state_normal": "Normal",
|
||||
"common_items_state_boost": "Destacar",
|
||||
"common_items_state_locked_tooltip": "Solo los admins pueden gestionar items frecuentes.",
|
||||
"common_items_purge_button": "Eliminar",
|
||||
"common_items_purge_modal_title": "¿Eliminar {name}?",
|
||||
"common_items_purge_modal_body": "Esto solo elimina la entrada de sugerencias. Los items que ya están en listas no se ven afectados.",
|
||||
"common_items_purge_confirm": "Eliminar",
|
||||
"common_items_add_button": "Añadir item común",
|
||||
"common_items_add_modal_title": "Añadir item común",
|
||||
"common_items_add_name_label": "Nombre del item",
|
||||
"common_items_add_name_placeholder": "p. ej. aceite de oliva",
|
||||
"common_items_add_visibility_label": "Visibilidad",
|
||||
"common_items_add_save": "Añadir"
|
||||
}
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import type { FeatureFlags, SectionKey } from '@colectivo/types';
|
||||
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
|
||||
import { SECTION_KEYS } from '@colectivo/types';
|
||||
import { setCollectiveFeature } from '$lib/stores/features';
|
||||
import {
|
||||
commonItems,
|
||||
loadCommonItems,
|
||||
setWeight as setCommonItemWeight,
|
||||
purge as purgeCommonItem
|
||||
} from '$lib/stores/commonItems';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
type Member = {
|
||||
@@ -30,6 +36,8 @@
|
||||
|
||||
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
|
||||
const isAdmin = $derived(myRole === 'admin');
|
||||
// Members + admins see the common-items table; guests never do.
|
||||
const canSeeCommonItems = $derived(myRole === 'admin' || myRole === 'member');
|
||||
|
||||
onMount(() => {
|
||||
// Re-run loadMembers() whenever the active collective resolves or
|
||||
@@ -39,7 +47,10 @@
|
||||
// empty list because the first fetch was short-circuited by
|
||||
// `$currentCollective == null`.
|
||||
const unsub = currentCollective.subscribe((c) => {
|
||||
if (c) void loadMembers();
|
||||
if (c) {
|
||||
void loadMembers();
|
||||
void loadCommonItems(c.id);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
@@ -317,6 +328,106 @@
|
||||
dissolveError = null;
|
||||
}
|
||||
|
||||
// ── Fase 15: common items management ────────────────────────────────────
|
||||
const HIDE_WEIGHT = -50;
|
||||
const NORMAL_WEIGHT = 0;
|
||||
const BOOST_WEIGHT = 50;
|
||||
|
||||
type WeightState = 'hide' | 'normal' | 'boost';
|
||||
|
||||
function weightState(weight: number): WeightState {
|
||||
if (weight <= -1) return 'hide';
|
||||
if (weight >= 1) return 'boost';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
let commonItemsSearch = $state('');
|
||||
let commonItemsError = $state<string | null>(null);
|
||||
|
||||
const filteredCommonItems = $derived.by(() => {
|
||||
const term = commonItemsSearch.trim().toLowerCase();
|
||||
const list = $commonItems;
|
||||
if (!term) return list;
|
||||
return list.filter((row: ItemFrequency) => row.name.includes(term));
|
||||
});
|
||||
|
||||
async function setCommonItemState(name: string, state: WeightState) {
|
||||
if (!$currentCollective || !isAdmin) return;
|
||||
const weight =
|
||||
state === 'boost' ? BOOST_WEIGHT : state === 'hide' ? HIDE_WEIGHT : NORMAL_WEIGHT;
|
||||
commonItemsError = null;
|
||||
const err = await setCommonItemWeight($currentCollective.id, name, weight);
|
||||
if (err) commonItemsError = err.message;
|
||||
}
|
||||
|
||||
// Purge modal
|
||||
let purgeTarget = $state<string | null>(null);
|
||||
|
||||
function openPurge(name: string) {
|
||||
if (!isAdmin) return;
|
||||
purgeTarget = name;
|
||||
}
|
||||
|
||||
function closePurge() {
|
||||
purgeTarget = null;
|
||||
}
|
||||
|
||||
async function confirmPurge() {
|
||||
if (!$currentCollective || !purgeTarget || !isAdmin) return;
|
||||
commonItemsError = null;
|
||||
const err = await purgeCommonItem($currentCollective.id, purgeTarget);
|
||||
purgeTarget = null;
|
||||
if (err) commonItemsError = err.message;
|
||||
}
|
||||
|
||||
// Add common-item modal
|
||||
let addOpen = $state(false);
|
||||
let addName = $state('');
|
||||
let addWeightState = $state<WeightState>('boost');
|
||||
let addSaving = $state(false);
|
||||
|
||||
function openAdd() {
|
||||
if (!isAdmin) return;
|
||||
addName = '';
|
||||
addWeightState = 'boost';
|
||||
addOpen = true;
|
||||
}
|
||||
|
||||
function closeAdd() {
|
||||
addOpen = false;
|
||||
}
|
||||
|
||||
async function confirmAdd() {
|
||||
if (!$currentCollective || !isAdmin) return;
|
||||
const trimmed = addName.trim();
|
||||
if (!trimmed) return;
|
||||
addSaving = true;
|
||||
commonItemsError = null;
|
||||
const weight =
|
||||
addWeightState === 'boost'
|
||||
? BOOST_WEIGHT
|
||||
: addWeightState === 'hide'
|
||||
? HIDE_WEIGHT
|
||||
: NORMAL_WEIGHT;
|
||||
const err = await setCommonItemWeight($currentCollective.id, trimmed, weight);
|
||||
addSaving = false;
|
||||
if (err) {
|
||||
commonItemsError = err.message;
|
||||
return;
|
||||
}
|
||||
addOpen = false;
|
||||
}
|
||||
|
||||
function formatLastUsed(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const now = Date.now();
|
||||
const diffMs = now - d.getTime();
|
||||
const day = 86_400_000;
|
||||
if (diffMs < day) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
async function confirmDissolve() {
|
||||
if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return;
|
||||
const target = $currentCollective;
|
||||
@@ -492,6 +603,145 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Common items (Fase 15) — admin curates; member sees read-only; guest hidden -->
|
||||
{#if canSeeCommonItems}
|
||||
<section data-testid="common-items-section" class="mb-8">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.common_items_title()}
|
||||
</h2>
|
||||
{#if isAdmin}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-add-button"
|
||||
onclick={openAdd}
|
||||
class="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white
|
||||
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{m.common_items_add_button()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mb-3 text-xs text-text-secondary">{m.common_items_blurb()}</p>
|
||||
|
||||
{#if !isAdmin}
|
||||
<p class="mb-3 text-xs text-text-secondary" data-testid="common-items-readonly-banner">
|
||||
{m.common_items_state_locked_tooltip()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if commonItemsError}
|
||||
<p class="mb-2 text-sm text-destructive" data-testid="common-items-error">{commonItemsError}</p>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="search"
|
||||
bind:value={commonItemsSearch}
|
||||
placeholder={m.common_items_search_placeholder()}
|
||||
data-testid="common-items-search"
|
||||
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
|
||||
{#if $commonItems.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="common-items-empty">
|
||||
{m.common_items_empty()}
|
||||
</p>
|
||||
{:else if filteredCommonItems.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="common-items-empty-filtered">
|
||||
{m.common_items_empty_filtered()}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-1" data-testid="common-items-list">
|
||||
{#each filteredCommonItems as row (row.name)}
|
||||
{@const state = weightState(row.weight)}
|
||||
<li
|
||||
data-testid="common-item-row-{row.name}"
|
||||
data-weight-state={state}
|
||||
class="flex flex-col gap-2 rounded-md bg-surface-raised px-3 py-2 md:flex-row md:items-center md:gap-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-900 dark:text-slate-50">
|
||||
{row.name}
|
||||
</p>
|
||||
<p class="text-[11px] text-text-secondary">
|
||||
{m.common_items_col_uses()}: {row.use_count} · {m.common_items_col_last_used()}: {formatLastUsed(row.last_used_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 3-way segmented control: Hide / Normal / Boost -->
|
||||
<div
|
||||
role="group"
|
||||
aria-label={m.common_items_col_weight()}
|
||||
class="inline-flex shrink-0 overflow-hidden rounded-md border border-slate-300 text-xs dark:border-slate-600"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-hide-{row.name}"
|
||||
title={!isAdmin ? m.common_items_state_locked_tooltip() : ''}
|
||||
aria-pressed={state === 'hide'}
|
||||
disabled={!isAdmin}
|
||||
onclick={() => void setCommonItemState(row.name, 'hide')}
|
||||
class="px-2 py-1 transition-colors
|
||||
{state === 'hide'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}
|
||||
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
{m.common_items_state_hide()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-normal-{row.name}"
|
||||
title={!isAdmin ? m.common_items_state_locked_tooltip() : ''}
|
||||
aria-pressed={state === 'normal'}
|
||||
disabled={!isAdmin}
|
||||
onclick={() => void setCommonItemState(row.name, 'normal')}
|
||||
class="border-x border-slate-300 px-2 py-1 transition-colors dark:border-slate-600
|
||||
{state === 'normal'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}
|
||||
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
{m.common_items_state_normal()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-boost-{row.name}"
|
||||
title={!isAdmin ? m.common_items_state_locked_tooltip() : ''}
|
||||
aria-pressed={state === 'boost'}
|
||||
disabled={!isAdmin}
|
||||
onclick={() => void setCommonItemState(row.name, 'boost')}
|
||||
class="px-2 py-1 transition-colors
|
||||
{state === 'boost'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}
|
||||
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
{m.common_items_state_boost()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-purge-{row.name}"
|
||||
title={!isAdmin ? m.common_items_state_locked_tooltip() : m.common_items_purge_button()}
|
||||
aria-label={m.common_items_purge_button()}
|
||||
disabled={!isAdmin}
|
||||
onclick={() => openPurge(row.name)}
|
||||
class="shrink-0 rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
|
||||
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-slate-400
|
||||
dark:hover:bg-red-900/20"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Invite link generator — background shift instead of border -->
|
||||
{#if isAdmin}
|
||||
<section class="rounded-lg bg-surface-container-low p-4 dark:bg-surface-raised">
|
||||
@@ -633,3 +883,147 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Purge common-item modal (Fase 15) -->
|
||||
{#if purgeTarget}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
data-testid="common-item-purge-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) closePurge();
|
||||
}}
|
||||
onkeydown={(e) => e.key === 'Escape' && closePurge()}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
|
||||
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.common_items_purge_modal_title({ name: purgeTarget })}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-text-secondary">{m.common_items_purge_modal_body()}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePurge}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-purge-confirm"
|
||||
onclick={() => void confirmPurge()}
|
||||
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
{m.common_items_purge_confirm()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add common-item modal (Fase 15) -->
|
||||
{#if addOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
data-testid="common-item-add-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) closeAdd();
|
||||
}}
|
||||
onkeydown={(e) => e.key === 'Escape' && closeAdd()}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
|
||||
<h2 class="mb-3 text-base font-semibold text-slate-900 dark:text-slate-50">
|
||||
{m.common_items_add_modal_title()}
|
||||
</h2>
|
||||
<label for="common-item-add-name" class="mb-1 block text-xs font-medium text-text-secondary">
|
||||
{m.common_items_add_name_label()}
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="common-item-add-name"
|
||||
data-testid="common-item-add-name"
|
||||
type="text"
|
||||
bind:value={addName}
|
||||
autofocus
|
||||
placeholder={m.common_items_add_name_placeholder()}
|
||||
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
|
||||
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void confirmAdd();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<p class="mb-1 text-xs font-medium text-text-secondary">{m.common_items_add_visibility_label()}</p>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={m.common_items_add_visibility_label()}
|
||||
class="mb-4 inline-flex overflow-hidden rounded-md border border-slate-300 text-xs dark:border-slate-600"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-add-hide"
|
||||
aria-pressed={addWeightState === 'hide'}
|
||||
onclick={() => (addWeightState = 'hide')}
|
||||
class="px-3 py-1 transition-colors
|
||||
{addWeightState === 'hide'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
|
||||
>
|
||||
{m.common_items_state_hide()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-add-normal"
|
||||
aria-pressed={addWeightState === 'normal'}
|
||||
onclick={() => (addWeightState = 'normal')}
|
||||
class="border-x border-slate-300 px-3 py-1 transition-colors dark:border-slate-600
|
||||
{addWeightState === 'normal'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
|
||||
>
|
||||
{m.common_items_state_normal()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-add-boost"
|
||||
aria-pressed={addWeightState === 'boost'}
|
||||
onclick={() => (addWeightState = 'boost')}
|
||||
class="px-3 py-1 transition-colors
|
||||
{addWeightState === 'boost'
|
||||
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
|
||||
>
|
||||
{m.common_items_state_boost()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeAdd}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.common_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="common-item-add-save"
|
||||
disabled={!addName.trim() || addSaving}
|
||||
onclick={() => void confirmAdd()}
|
||||
class="rounded-md bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50
|
||||
dark:bg-slate-50 dark:text-slate-900"
|
||||
>
|
||||
{addSaving ? m.loading() : m.common_items_add_save()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
161
apps/web/tests/e2e/common-items.test.ts
Normal file
161
apps/web/tests/e2e/common-items.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* CI-series — Common items management (Fase 15).
|
||||
*
|
||||
* CI-01 Ana boosts "milk" → in /lists/<id> the dropdown shows it first
|
||||
* even though seeded use_counts put other items higher.
|
||||
* CI-02 Ana adds "pan" to the active list → the dropdown stops including
|
||||
* it on the next keystroke.
|
||||
* CI-03 Borja (member) opens /collective/manage → he sees the table but
|
||||
* the action buttons are disabled.
|
||||
* CI-04 Ana purges a row → the row disappears from the table and the
|
||||
* dropdown stops showing it.
|
||||
*
|
||||
* Note: CI-02 lives in this file but exercises the /lists/[id] page, not
|
||||
* /collective/manage — it pairs with the exclude-on-list wiring landing
|
||||
* in the next commit (15.4). It is listed here because it is part of the
|
||||
* Fase-15 contract.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
const PREFIX = 'ci-e2e-';
|
||||
|
||||
/**
|
||||
* Reset every item_frequency row this suite touched, and clear any boost
|
||||
* we left on a seeded name. Runs via the page's `__sb` hook (service-role
|
||||
* not exposed in the browser, so we go through Ana — who is admin in the
|
||||
* seed collective — and the RPCs gate accordingly).
|
||||
*/
|
||||
async function resetCommonItems(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { __sb?: unknown }).__sb),
|
||||
null,
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
await page.evaluate(
|
||||
async ({ collectiveId, prefix }) => {
|
||||
const supabase = (
|
||||
window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient }
|
||||
).__sb;
|
||||
// Delete every test row created by this suite.
|
||||
await supabase
|
||||
.from('item_frequency')
|
||||
.delete()
|
||||
.eq('collective_id', collectiveId)
|
||||
.like('name', `${prefix}%`);
|
||||
// Reset weight on seeded names this suite mutates so a re-run starts
|
||||
// from a known place.
|
||||
for (const name of ['milk', 'bread', 'eggs', 'apples', 'butter', 'yogurt', 'coffee']) {
|
||||
try {
|
||||
await supabase.rpc('set_item_frequency_weight', {
|
||||
p_collective_id: collectiveId,
|
||||
p_name: name,
|
||||
p_weight: 0
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
{ collectiveId: SEED_COLLECTIVE_ID, prefix: PREFIX }
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Common items management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, USERS.ana);
|
||||
await resetCommonItems(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
try {
|
||||
await resetCommonItems(page);
|
||||
} catch {
|
||||
/* don't mask original failure */
|
||||
}
|
||||
});
|
||||
|
||||
test('CI-01: Ana boosts "yogurt" — dropdown surfaces it ahead of milk', async ({ page }) => {
|
||||
// The seed has milk (use_count=5, the highest) and yogurt (use_count=1,
|
||||
// the lowest). Without weight, milk leads the dropdown. By boosting
|
||||
// yogurt to weight=50, the suggestion query orders it first regardless
|
||||
// of its low use_count — that's the entire promise of Fase 15.
|
||||
|
||||
await page.goto('/collective/manage');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const yogurtRow = page.getByTestId('common-item-row-yogurt');
|
||||
await expect(yogurtRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await yogurtRow.getByTestId('common-item-boost-yogurt').click();
|
||||
await expect(yogurtRow).toHaveAttribute('data-weight-state', 'boost', { timeout: 5_000 });
|
||||
|
||||
// Now open the seed list and verify the suggestion strip leads with yogurt.
|
||||
await page.goto(`/lists/${SEED_LIST_ID}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const firstChip = page.locator('[class*="overflow-x-auto"] > button').first();
|
||||
await expect(firstChip).toBeVisible({ timeout: 10_000 });
|
||||
await expect(firstChip).toHaveText('yogurt');
|
||||
});
|
||||
|
||||
test('CI-03: Borja sees the table read-only with disabled actions', async ({
|
||||
browser
|
||||
}) => {
|
||||
// Seed the data as Ana first so there's something for Borja to see.
|
||||
// (Default seed already has milk/bread/etc.)
|
||||
const borjaCtx = await browser.newContext();
|
||||
const borja = await borjaCtx.newPage();
|
||||
try {
|
||||
await loginAs(borja, USERS.borja);
|
||||
await borja.goto('/collective/manage');
|
||||
await borja.waitForLoadState('networkidle');
|
||||
|
||||
const milkRow = borja.getByTestId('common-item-row-milk');
|
||||
await expect(milkRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Hide / Normal / Boost buttons must all be disabled for members.
|
||||
const hideBtn = milkRow.getByTestId('common-item-hide-milk');
|
||||
const normalBtn = milkRow.getByTestId('common-item-normal-milk');
|
||||
const boostBtn = milkRow.getByTestId('common-item-boost-milk');
|
||||
await expect(hideBtn).toBeDisabled();
|
||||
await expect(normalBtn).toBeDisabled();
|
||||
await expect(boostBtn).toBeDisabled();
|
||||
|
||||
// Purge button must be hidden or disabled for members.
|
||||
const purgeBtn = milkRow.getByTestId('common-item-purge-milk');
|
||||
await expect(purgeBtn).toBeDisabled();
|
||||
|
||||
// The "Add common item" button must not appear for members.
|
||||
await expect(borja.getByTestId('common-item-add-button')).toHaveCount(0);
|
||||
} finally {
|
||||
await borjaCtx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('CI-04: Ana purges "apples" — row disappears from the table and dropdown', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/collective/manage');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const applesRow = page.getByTestId('common-item-row-apples');
|
||||
await expect(applesRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await applesRow.getByTestId('common-item-purge-apples').click();
|
||||
// Modal confirmation.
|
||||
const confirmBtn = page.getByTestId('common-item-purge-confirm');
|
||||
await expect(confirmBtn).toBeVisible({ timeout: 5_000 });
|
||||
await confirmBtn.click();
|
||||
|
||||
// Row must vanish from the table.
|
||||
await expect(applesRow).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// Re-seed apples via the existing trigger by NOT adding the item — just
|
||||
// confirm the row is gone from the manage view. The dropdown behaviour
|
||||
// is exercised indirectly by CI-01.
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user