mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
feat(board): per-lane sort override in the kebab menu (TASK-1673) (#677)
Add a "Sort lane by" drill-down to LaneActionsMenu — an ephemeral per-lane override on top of the page-wide sort. BoardView holds the override map (Record<lane, SortMode>, not persisted), applies the effective mode (override ?? page default) per lane in propColumnData, and disables drag per-lane under a non-manual effective sort. The submenu lists the same options as the toolbar (Priority hidden when no priority field) plus a "Page default" entry to clear the override, with a check on the active one. Sort is a view preference, available to everyone — so the kebab now shows for any non-empty lane (not just editors), and the menu's separators are section-gated so nothing dangles when a viewer sees only Sort.
This commit is contained in:
@@ -82,22 +82,18 @@
|
||||
openMenuColumn = null;
|
||||
}
|
||||
|
||||
// Any bulk verb wired (each encodes its own owner/editor permission).
|
||||
// The bulk menu entries only render for a NON-empty lane, so kebab
|
||||
// visibility is computed per-lane below as `onCreateInColumn ||
|
||||
// (laneHasItems && hasBulkActions)` — otherwise a role-only bulk
|
||||
// editor would get a ⋯ that opens an empty panel on an empty lane
|
||||
// (TASK-1672 / Codex round 5).
|
||||
let hasBulkActions = $derived(
|
||||
!!(
|
||||
onArchiveColumn ||
|
||||
onMoveColumn ||
|
||||
onTagColumn ||
|
||||
onUntagColumn ||
|
||||
onSetPriorityColumn ||
|
||||
onAssignColumn
|
||||
)
|
||||
);
|
||||
// Ephemeral per-lane sort overrides (TASK-1673): a lane sorts by its
|
||||
// override when set, else the page-wide `sortMode`. Not persisted —
|
||||
// cleared on reload. Available to everyone (sort is a view preference).
|
||||
let laneSortOverrides = $state<Record<string, SortMode>>({});
|
||||
function setLaneSort(colValue: string, mode: SortMode | null) {
|
||||
if (mode === null) {
|
||||
delete laneSortOverrides[colValue];
|
||||
} else {
|
||||
laneSortOverrides[colValue] = mode;
|
||||
}
|
||||
}
|
||||
const laneSortFor = (colValue: string): SortMode => laneSortOverrides[colValue] ?? sortMode;
|
||||
|
||||
// Dismiss the open lane menu on any click outside it (mirrors the
|
||||
// QuickActionsMenu pattern). The menu markup lives under
|
||||
@@ -201,12 +197,12 @@
|
||||
}
|
||||
// `preserveOrder` opts out of the in-column sort so search rank
|
||||
// from the parent isn't overridden — TASK-1367. Otherwise sort
|
||||
// each lane by the page-wide sort mode (TASK-1670); 'manual'
|
||||
// each lane by its effective mode — the per-lane override if set,
|
||||
// else the page-wide sort (TASK-1670 / TASK-1673); 'manual'
|
||||
// resolves to the stored sort_order, preserving prior behavior.
|
||||
if (!preserveOrder) {
|
||||
const cmp = itemComparator(sortMode, collection);
|
||||
for (const col of columns) {
|
||||
result[col].sort(cmp);
|
||||
result[col].sort(itemComparator(laneSortFor(col), collection));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -339,7 +335,9 @@
|
||||
onclick={() => onCreateInColumn?.(colValue)}
|
||||
>+</button>
|
||||
{/if}
|
||||
{#if onCreateInColumn || (colItems.length > 0 && hasBulkActions)}
|
||||
<!-- Kebab shows for create OR any non-empty lane (sort is
|
||||
always available, even to viewers — TASK-1673). -->
|
||||
{#if onCreateInColumn || colItems.length > 0}
|
||||
<div class="lane-menu-wrap">
|
||||
<button
|
||||
class="lane-btn lane-menu-btn"
|
||||
@@ -358,6 +356,9 @@
|
||||
{filtered}
|
||||
{members}
|
||||
{tagSuggestions}
|
||||
{sortMode}
|
||||
laneSort={laneSortOverrides[colValue]}
|
||||
onSetLaneSort={(m) => setLaneSort(colValue, m)}
|
||||
onClose={closeMenu}
|
||||
onAddItem={onCreateInColumn ? () => onCreateInColumn?.(colValue) : undefined}
|
||||
onArchive={onArchiveColumn ? () => onArchiveColumn?.(colItems) : undefined}
|
||||
@@ -389,7 +390,7 @@
|
||||
// under any non-manual page sort (TASK-1670): the
|
||||
// lane is comparator-ordered, so a drag couldn't
|
||||
// stick anyway.
|
||||
dragDisabled: isMobile || !canEdit || preserveOrder || sortMode !== 'manual'
|
||||
dragDisabled: isMobile || !canEdit || preserveOrder || laneSortFor(colValue) !== 'manual'
|
||||
}}
|
||||
onconsider={(e) => handleConsider(colValue, e)}
|
||||
onfinalize={(e) => handleFinalize(colValue, e)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Item, Collection } from '$lib/types';
|
||||
import { parseSchema } from '$lib/types';
|
||||
import { SORT_OPTIONS, priorityField, type SortMode } from '$lib/collections/itemSort';
|
||||
|
||||
interface Props {
|
||||
/** The lane's CURRENTLY-FILTERED items — every action operates on these. */
|
||||
@@ -15,6 +16,13 @@
|
||||
members: { user_id: string; user_name?: string }[];
|
||||
/** Workspace tag suggestions for "Tag all". */
|
||||
tagSuggestions: string[];
|
||||
// Page-wide sort default + this lane's ephemeral override (TASK-1673).
|
||||
// `laneSort` undefined = follow the page default. onSetLaneSort(null)
|
||||
// clears the override. Sorting is a view preference, available to
|
||||
// everyone (including viewers) — not gated on edit permission.
|
||||
sortMode?: SortMode;
|
||||
laneSort?: SortMode;
|
||||
onSetLaneSort?: (mode: SortMode | null) => void;
|
||||
onClose: () => void;
|
||||
// Each action is optional: the caller passes only the ones the
|
||||
// current user is permitted to perform (the single `+` create is
|
||||
@@ -37,6 +45,9 @@
|
||||
filtered,
|
||||
members,
|
||||
tagSuggestions,
|
||||
sortMode = 'manual',
|
||||
laneSort = undefined,
|
||||
onSetLaneSort,
|
||||
onClose,
|
||||
onAddItem,
|
||||
onArchive,
|
||||
@@ -47,7 +58,7 @@
|
||||
onAssign
|
||||
}: Props = $props();
|
||||
|
||||
type View = 'root' | 'move' | 'tag' | 'untag' | 'priority' | 'assign';
|
||||
type View = 'root' | 'move' | 'tag' | 'untag' | 'priority' | 'assign' | 'sort';
|
||||
let view = $state<View>('root');
|
||||
let confirmArchive = $state(false);
|
||||
let tagInput = $state('');
|
||||
@@ -70,6 +81,18 @@
|
||||
);
|
||||
let priorityOptions = $derived(priorityFieldDef?.options ?? []);
|
||||
|
||||
// Sort options offered in the "Sort lane by" submenu — same set the
|
||||
// page toolbar uses, minus Priority when the collection has no priority
|
||||
// field (TASK-1673). `effectiveSort` is the lane's current order: its
|
||||
// override if set, else the page default.
|
||||
let sortOptions = $derived(
|
||||
priorityField(collection) ? SORT_OPTIONS : SORT_OPTIONS.filter((o) => o.value !== 'priority')
|
||||
);
|
||||
let effectiveSort = $derived(laneSort ?? sortMode);
|
||||
let sortLabel = $derived(
|
||||
SORT_OPTIONS.find((o) => o.value === effectiveSort)?.label ?? 'Manual'
|
||||
);
|
||||
|
||||
// Untag offers only the tags actually present on the lane's items.
|
||||
let laneTags = $derived.by(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -83,6 +106,20 @@
|
||||
return [...set].sort();
|
||||
});
|
||||
|
||||
// Section presence — drives separators so none dangle when a section
|
||||
// (e.g. the bulk verbs for a viewer) is empty. Declared after laneTags
|
||||
// since hasVerbs references it.
|
||||
let hasSort = $derived(count > 0 && !!onSetLaneSort);
|
||||
let hasVerbs = $derived(
|
||||
count > 0 &&
|
||||
(!!(onMove && moveTargets.length) ||
|
||||
!!onTag ||
|
||||
!!(onUntag && laneTags.length) ||
|
||||
!!(onSetPriority && priorityOptions.length) ||
|
||||
!!(onAssign && members.length))
|
||||
);
|
||||
let hasArchive = $derived(count > 0 && !!onArchive);
|
||||
|
||||
function fmt(v: string): string {
|
||||
return v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -113,9 +150,16 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if count > 0}
|
||||
<div class="lane-menu-sep"></div>
|
||||
{#if hasSort}
|
||||
{#if onAddItem}<div class="lane-menu-sep"></div>{/if}
|
||||
<button class="lane-menu-item" role="menuitem" onclick={(e) => run(e, () => (view = 'sort'))}>
|
||||
<span class="lmi-icon" aria-hidden="true">↕</span> Sort lane by
|
||||
<span class="lmi-chevron">{sortLabel} ›</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if hasVerbs}
|
||||
{#if onAddItem || hasSort}<div class="lane-menu-sep"></div>{/if}
|
||||
{#if onMove && moveTargets.length > 0}
|
||||
<button class="lane-menu-item" role="menuitem" onclick={(e) => run(e, () => (view = 'move'))}>
|
||||
<span class="lmi-icon" aria-hidden="true">→</span> Move all to <span class="lmi-chevron">›</span>
|
||||
@@ -145,22 +189,22 @@
|
||||
<span class="lmi-icon" aria-hidden="true">👤</span> Assign all <span class="lmi-chevron">›</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if onArchive}
|
||||
<div class="lane-menu-sep"></div>
|
||||
{#if confirmArchive}
|
||||
<div class="lane-menu-confirm">
|
||||
<span>Archive {count} item{count === 1 ? '' : 's'}{scopeNote}?</span>
|
||||
<div class="lmc-actions">
|
||||
<button class="lmc-yes" onclick={(e) => run(e, () => { onArchive?.(); onClose(); })}>Archive</button>
|
||||
<button class="lmc-no" onclick={(e) => run(e, () => (confirmArchive = false))}>Cancel</button>
|
||||
</div>
|
||||
{#if hasArchive}
|
||||
{#if onAddItem || hasSort || hasVerbs}<div class="lane-menu-sep"></div>{/if}
|
||||
{#if confirmArchive}
|
||||
<div class="lane-menu-confirm">
|
||||
<span>Archive {count} item{count === 1 ? '' : 's'}{scopeNote}?</span>
|
||||
<div class="lmc-actions">
|
||||
<button class="lmc-yes" onclick={(e) => run(e, () => { onArchive?.(); onClose(); })}>Archive</button>
|
||||
<button class="lmc-no" onclick={(e) => run(e, () => (confirmArchive = false))}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="lane-menu-item lmi-danger" role="menuitem" onclick={(e) => run(e, () => (confirmArchive = true))}>
|
||||
<span class="lmi-icon" aria-hidden="true">🗃</span> Archive all ({count}{scopeNote})
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="lane-menu-item lmi-danger" role="menuitem" onclick={(e) => run(e, () => (confirmArchive = true))}>
|
||||
<span class="lmi-icon" aria-hidden="true">🗃</span> Archive all ({count}{scopeNote})
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if view === 'move'}
|
||||
@@ -211,6 +255,31 @@
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if view === 'sort'}
|
||||
<button class="lane-menu-back" onclick={(e) => run(e, () => (view = 'root'))}>‹ Sort lane by</button>
|
||||
<!-- Clear the ephemeral override → follow the page-wide sort. -->
|
||||
<button
|
||||
class="lane-menu-item"
|
||||
role="menuitemradio"
|
||||
aria-checked={laneSort === undefined}
|
||||
onclick={(e) => run(e, () => { onSetLaneSort?.(null); onClose(); })}
|
||||
>
|
||||
<span class="lmi-check" aria-hidden="true">{laneSort === undefined ? '✓' : ''}</span>
|
||||
Page default
|
||||
<span class="lmi-chevron">{SORT_OPTIONS.find((o) => o.value === sortMode)?.label}</span>
|
||||
</button>
|
||||
<div class="lane-menu-sep"></div>
|
||||
{#each sortOptions as opt (opt.value)}
|
||||
<button
|
||||
class="lane-menu-item"
|
||||
role="menuitemradio"
|
||||
aria-checked={laneSort === opt.value}
|
||||
onclick={(e) => run(e, () => { onSetLaneSort?.(opt.value); onClose(); })}
|
||||
>
|
||||
<span class="lmi-check" aria-hidden="true">{laneSort === opt.value ? '✓' : ''}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -266,6 +335,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lmi-check {
|
||||
width: 1.1em;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-blue);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.lmi-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user