feat(views): gate drag/archive in ListView/BoardView on canEditCollection (TASK-1106) (#420)

The collection page's ListView and BoardView allowed all roles to drag
items, drag-status-change, reorder groups/columns, and archive groups.
Server enforces edit per-item and per-collection on these mutations
(handlers_items.go, handlers_role_board.go) — UI now matches.

Changes:
- ListView + BoardView accept a `canEdit?: boolean` prop (default true to
  preserve behavior in existing callers).
- ListView: dndzone for groups + intra-group items receives
  `dragDisabled: !canEdit`. Group drag handle and archive-group button
  hidden when !canEdit.
- BoardView: column-cards dndzone receives `dragDisabled: isMobile || !canEdit`.
  Column-header drag (column reorder) gated via `draggable={canEdit}` and
  conditional drag handlers. Column-drag-handle indicator and
  archive-column button hidden when !canEdit.
- Collection page passes `canEdit={canEditThisCollection}` to both views.

Scope note: per-item drag gating (e.g. a guest with ItemGrant.edit on one
item dragging just that one card) is not implemented — svelte-dnd-action
only supports zone-level dragDisabled. Achieving per-item would require
switching to dragHandleZone+dragHandle and shipping an explicit handle UI
for everyone, which is a larger UX change. Server already enforces per-item
edit on the resulting mutations, so no security gap. Documented as a
follow-up if needed.

TableView: excluded from drag/archive scope — no drag handlers to gate.
Status-cell editing is already gated via FieldEditor's readonly prop from
TASK-1105.

Parent: PLAN-1100.
This commit is contained in:
xarmian
2026-05-05 09:47:55 -04:00
committed by GitHub
parent a8b158829b
commit fe9b76ab93
3 changed files with 43 additions and 12 deletions
@@ -20,9 +20,16 @@
oncreate?: () => void;
itemProgress?: Record<string, { total: number; done: number }>;
progressLabel?: string;
/**
* canEdit gates drag-to-reorder, drag-to-status-change, column
* reordering, and the archive-column button. See ListView.svelte
* for the rationale (zone-level gate; per-item is a follow-up).
* Default true preserves behavior in callers that don't pass it.
*/
canEdit?: boolean;
}
let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, itemProgress, progressLabel = 'tasks' }: Props = $props();
let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, itemProgress, progressLabel = 'tasks', canEdit = true }: Props = $props();
let confirmArchiveColumn = $state<string | null>(null);
let isMobile = $state(false);
@@ -221,17 +228,19 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="column-header {columnCssClass(colValue)}"
draggable="true"
draggable={canEdit}
role="toolbar"
tabindex="0"
ondragstart={(e) => handleColumnDragStart(e, colValue)}
ondragend={handleColumnDragEnd}
ondragstart={canEdit ? (e) => handleColumnDragStart(e, colValue) : undefined}
ondragend={canEdit ? handleColumnDragEnd : undefined}
>
<span class="column-drag-handle" title="Drag to reorder"></span>
{#if canEdit}
<span class="column-drag-handle" title="Drag to reorder"></span>
{/if}
<span class="column-name">{formatLabel(colValue)}</span>
<div class="column-actions">
<span class="column-count">{colItems.length}</span>
{#if onArchiveColumn && colItems.length > 0}
{#if canEdit && onArchiveColumn && colItems.length > 0}
{#if confirmArchiveColumn === colValue}
<span class="archive-confirm">
<button class="archive-yes" onclick={() => { onArchiveColumn(colItems); confirmArchiveColumn = null; }}>Archive {colItems.length}?</button>
@@ -256,7 +265,7 @@
type: 'board-card',
dropTargetClasses: ['drop-target'],
delayTouchStart: touchDragDelayMs,
dragDisabled: isMobile
dragDisabled: isMobile || !canEdit
}}
onconsider={(e) => handleConsider(colValue, e)}
onfinalize={(e) => handleFinalize(colValue, e)}
@@ -22,6 +22,21 @@
oncreate?: () => void;
itemProgress?: Record<string, { total: number; done: number }>;
progressLabel?: string;
/**
* canEdit gates drag-to-reorder, drag-to-status-change, and the
* archive-group button. Default true preserves existing behavior in
* call sites that don't pass it. Pass `workspaceStore.canEditCollection(collection.id)`
* (PLAN-1100 / TASK-1106) — the gate is collection-level because
* svelte-dnd-action only supports zone-level dragDisabled.
*
* Per-item gating (e.g. a guest with `ItemGrant.edit` on a single
* item dragging just that one card) would require switching to
* `dragHandleZone` + `dragHandle` actions, which changes the drag
* UX for everyone (whole-card → explicit-handle). Documented as a
* follow-up if needed; the server already enforces per-item edit
* on the resulting mutations, so no security gap here.
*/
canEdit?: boolean;
}
let {
@@ -37,7 +52,8 @@
onGroupReorder,
oncreate,
itemProgress,
progressLabel = 'tasks'
progressLabel = 'tasks',
canEdit = true
}: Props = $props();
let confirmArchiveGroup = $state<string | null>(null);
@@ -214,7 +230,8 @@
(BUG-641): a 500ms long-press is required before drag activates,
which matches the existing intra-group item behaviour and lets
ordinary taps/scrolls pass through unmolested. */
delayTouchStart: touchDragDelayMs
delayTouchStart: touchDragDelayMs,
dragDisabled: !canEdit
}}
onconsider={handleGroupConsider}
onfinalize={handleGroupFinalize}
@@ -231,14 +248,16 @@
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(groupName); } }}
aria-expanded={!collapsedGroups.has(groupName)}
>
<span class="group-drag-handle" title="Drag to reorder"></span>
{#if canEdit}
<span class="group-drag-handle" title="Drag to reorder"></span>
{/if}
<span class="collapse-icon" class:collapsed={collapsedGroups.has(groupName)}
>&#9662;</span
>
<span class="group-title">{formatLabel(groupName)}</span>
<span class="group-actions">
<span class="group-count">{itemCount(grpItems)}</span>
{#if onArchiveGroup && itemCount(grpItems) > 0}
{#if canEdit && onArchiveGroup && itemCount(grpItems) > 0}
{#if confirmArchiveGroup === groupName}
<span class="archive-confirm">
<button class="archive-yes" onclick={(e) => { e.stopPropagation(); onArchiveGroup(grpItems); confirmArchiveGroup = null; }}>Archive {itemCount(grpItems)}?</button>
@@ -264,7 +283,8 @@
flipDurationMs,
type: 'list-item',
dropTargetClasses: ['drop-target'],
delayTouchStart: touchDragDelayMs
delayTouchStart: touchDragDelayMs,
dragDisabled: !canEdit
}}
onconsider={(e) => handleConsider(groupName, e)}
onfinalize={(e) => handleFinalize(groupName, e)}
@@ -1276,6 +1276,7 @@
oncreate={canEditThisCollection ? openQuickCreate : undefined}
{itemProgress}
{progressLabel}
canEdit={canEditThisCollection}
/>
{:else if viewMode === 'table'}
<TableView
@@ -1302,6 +1303,7 @@
oncreate={canEditThisCollection ? openQuickCreate : undefined}
{itemProgress}
{progressLabel}
canEdit={canEditThisCollection}
/>
{/if}
{/if}