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_section_state_unset": "No opinion",
|
||||||
"admin_server_info": "Server info",
|
"admin_server_info": "Server info",
|
||||||
"admin_error_forbidden": "You don't have access to this area.",
|
"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_section_state_unset": "Sin opinión",
|
||||||
"admin_server_info": "Información del servidor",
|
"admin_server_info": "Información del servidor",
|
||||||
"admin_error_forbidden": "No tienes acceso a esta zona.",
|
"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 { currentUser } from '$lib/stores/auth';
|
||||||
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
||||||
import Avatar from '$lib/components/Avatar.svelte';
|
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 { SECTION_KEYS } from '@colectivo/types';
|
||||||
import { setCollectiveFeature } from '$lib/stores/features';
|
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';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
type Member = {
|
type Member = {
|
||||||
@@ -30,6 +36,8 @@
|
|||||||
|
|
||||||
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
|
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
|
||||||
const isAdmin = $derived(myRole === 'admin');
|
const isAdmin = $derived(myRole === 'admin');
|
||||||
|
// Members + admins see the common-items table; guests never do.
|
||||||
|
const canSeeCommonItems = $derived(myRole === 'admin' || myRole === 'member');
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Re-run loadMembers() whenever the active collective resolves or
|
// Re-run loadMembers() whenever the active collective resolves or
|
||||||
@@ -39,7 +47,10 @@
|
|||||||
// empty list because the first fetch was short-circuited by
|
// empty list because the first fetch was short-circuited by
|
||||||
// `$currentCollective == null`.
|
// `$currentCollective == null`.
|
||||||
const unsub = currentCollective.subscribe((c) => {
|
const unsub = currentCollective.subscribe((c) => {
|
||||||
if (c) void loadMembers();
|
if (c) {
|
||||||
|
void loadMembers();
|
||||||
|
void loadCommonItems(c.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return unsub;
|
return unsub;
|
||||||
});
|
});
|
||||||
@@ -317,6 +328,106 @@
|
|||||||
dissolveError = null;
|
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() {
|
async function confirmDissolve() {
|
||||||
if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return;
|
if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return;
|
||||||
const target = $currentCollective;
|
const target = $currentCollective;
|
||||||
@@ -492,6 +603,145 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 -->
|
<!-- Invite link generator — background shift instead of border -->
|
||||||
{#if isAdmin}
|
{#if isAdmin}
|
||||||
<section class="rounded-lg bg-surface-container-low p-4 dark:bg-surface-raised">
|
<section class="rounded-lg bg-surface-container-low p-4 dark:bg-surface-raised">
|
||||||
@@ -633,3 +883,147 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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