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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user