mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
feat(collections): page-wide sort control + priority-weight helper (TASK-1670, closes IDEA-1648) (#673)
Add a Sort dropdown to the collection toolbar (Manual, Priority, Recently updated, Created, A→Z) applied within each lane/group in both BoardView and ListView. A shared helper (lib/collections/itemSort.ts) builds the comparator; the priority weight reads the `priority` select field's own options order, so high/medium/low and must/should/nice both rank naturally (top option = highest) with no hardcoded map. - 'manual' (default) resolves to the stored sort_order — prior behavior. - Non-manual sorts disable item drag (like the preserveOrder seam): a comparator-ordered lane can't accept a drag, so DnD is suppressed. - 'Priority' is hidden when the collection has no priority field; a stale persisted 'priority' falls back to 'manual'. - Sort persists per collection in localStorage (mirrors view mode). - Table view keeps its own column sorting; the control is board/list only.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// Page-wide item sorting (TASK-1670 / IDEA-1648).
|
||||
//
|
||||
// A single comparator factory shared by BoardView (within-lane) and
|
||||
// ListView (within-group) so a sort chosen on the collection toolbar
|
||||
// applies consistently in both. `manual` preserves the stored
|
||||
// `sort_order` (the drag-to-reorder order) and is the default.
|
||||
import type { Item, Collection } from '$lib/types';
|
||||
import { parseFields, parseSchema } from '$lib/types';
|
||||
|
||||
export type SortMode = 'manual' | 'priority' | 'updated' | 'created' | 'title';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'updated', label: 'Recently updated' },
|
||||
{ value: 'created', label: 'Created' },
|
||||
{ value: 'title', label: 'A → Z' }
|
||||
];
|
||||
|
||||
// The select field a "Priority" sort ranks by. Convention is the field
|
||||
// keyed `priority` (e.g. tasks high/medium/low, conventions
|
||||
// must/should/nice-to-have). Returns undefined when the collection has
|
||||
// no such field, so the toolbar can hide the Priority option.
|
||||
export function priorityField(collection: Collection) {
|
||||
const schema = parseSchema(collection);
|
||||
return schema.fields.find((f) => f.key === 'priority' && f.type === 'select');
|
||||
}
|
||||
|
||||
// Priority weight = the value's index in the field's `options` array.
|
||||
// Options are authored top-to-bottom (high…low, must…nice), so a lower
|
||||
// index is a higher priority. Items missing the field, or carrying a
|
||||
// value not in the schema, sort last. Reading the field's own option
|
||||
// order means different priority vocabularies rank naturally without a
|
||||
// hardcoded weight map.
|
||||
function priorityWeight(item: Item, options: string[]): number {
|
||||
const val = parseFields(item).priority;
|
||||
const idx = typeof val === 'string' ? options.indexOf(val) : -1;
|
||||
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx;
|
||||
}
|
||||
|
||||
function timeValue(s: string | undefined): number {
|
||||
if (!s) return 0;
|
||||
const t = Date.parse(s);
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
// Build the within-group comparator for `mode`. `priority` falls back to
|
||||
// `sort_order` as a stable tie-break; the date modes sort newest-first;
|
||||
// `title` is case-insensitive A→Z. `manual` (default) is the stored
|
||||
// `sort_order`, preserving drag ordering.
|
||||
export function itemComparator(
|
||||
mode: SortMode,
|
||||
collection: Collection
|
||||
): (a: Item, b: Item) => number {
|
||||
switch (mode) {
|
||||
case 'priority': {
|
||||
const options = priorityField(collection)?.options ?? [];
|
||||
return (a, b) =>
|
||||
priorityWeight(a, options) - priorityWeight(b, options) ||
|
||||
a.sort_order - b.sort_order;
|
||||
}
|
||||
case 'updated':
|
||||
return (a, b) => timeValue(b.updated_at) - timeValue(a.updated_at);
|
||||
case 'created':
|
||||
return (a, b) => timeValue(b.created_at) - timeValue(a.created_at);
|
||||
case 'title':
|
||||
return (a, b) =>
|
||||
(a.title || '').localeCompare(b.title || '', undefined, {
|
||||
sensitivity: 'base'
|
||||
});
|
||||
case 'manual':
|
||||
default:
|
||||
return (a, b) => a.sort_order - b.sort_order;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Item, Collection } from '$lib/types';
|
||||
import { parseSchema, parseFields } from '$lib/types';
|
||||
import { itemComparator, type SortMode } from '$lib/collections/itemSort';
|
||||
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
|
||||
import type { DndEvent } from 'svelte-dnd-action';
|
||||
import ItemCard from './ItemCard.svelte';
|
||||
@@ -41,9 +42,16 @@
|
||||
* two matches share a column.
|
||||
*/
|
||||
preserveOrder?: boolean;
|
||||
/**
|
||||
* Page-wide sort applied within each lane (TASK-1670). 'manual'
|
||||
* (default) keeps the stored sort_order — the drag order. Any
|
||||
* other mode also disables item drag, since reordering a sorted
|
||||
* lane would be meaningless (the comparator would re-sort it).
|
||||
*/
|
||||
sortMode?: SortMode;
|
||||
}
|
||||
|
||||
let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, onCreateInColumn, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false }: Props = $props();
|
||||
let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, onCreateInColumn, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false, sortMode = 'manual' }: Props = $props();
|
||||
|
||||
let confirmArchiveColumn = $state<string | null>(null);
|
||||
// Which lane's ⋯ menu is open (null = none). The menu is the new home
|
||||
@@ -163,10 +171,13 @@
|
||||
}
|
||||
}
|
||||
// `preserveOrder` opts out of the in-column sort so search rank
|
||||
// from the parent isn't overridden — TASK-1367.
|
||||
// from the parent isn't overridden — TASK-1367. Otherwise sort
|
||||
// each lane by the page-wide sort mode (TASK-1670); '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((a, b) => a.sort_order - b.sort_order);
|
||||
result[col].sort(cmp);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -362,8 +373,11 @@
|
||||
// requested rank-preserving order (search
|
||||
// active) — otherwise a drag would persist the
|
||||
// relevance-ranked subset order as the stored
|
||||
// `sort_order`. TASK-1367 / Codex R5.
|
||||
dragDisabled: isMobile || !canEdit || preserveOrder
|
||||
// `sort_order`. TASK-1367 / Codex R5. Also disable
|
||||
// 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'
|
||||
}}
|
||||
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, parseFields } from '$lib/types';
|
||||
import { itemComparator, type SortMode } from '$lib/collections/itemSort';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
|
||||
import type { DndEvent } from 'svelte-dnd-action';
|
||||
@@ -46,6 +47,13 @@
|
||||
* status column.
|
||||
*/
|
||||
preserveOrder?: boolean;
|
||||
/**
|
||||
* Page-wide sort applied within each group (TASK-1670). 'manual'
|
||||
* (default) keeps the stored sort_order — the drag order. Any
|
||||
* other mode also disables item drag, since reordering a sorted
|
||||
* group would be meaningless.
|
||||
*/
|
||||
sortMode?: SortMode;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -63,7 +71,8 @@
|
||||
itemProgress,
|
||||
progressLabel = 'tasks',
|
||||
canEdit = true,
|
||||
preserveOrder = false
|
||||
preserveOrder = false,
|
||||
sortMode = 'manual'
|
||||
}: Props = $props();
|
||||
|
||||
let confirmArchiveGroup = $state<string | null>(null);
|
||||
@@ -151,9 +160,12 @@
|
||||
// `preserveOrder` opts out of the in-group sort so a parent that
|
||||
// already sorted by relevance (search active) doesn't get its
|
||||
// ranking clobbered when two matches share a column. TASK-1367.
|
||||
// Otherwise sort each group by the page-wide sort mode
|
||||
// (TASK-1670); 'manual' resolves to the stored sort_order.
|
||||
if (!preserveOrder) {
|
||||
const cmp = itemComparator(sortMode, collection);
|
||||
for (const key of Object.keys(result)) {
|
||||
result[key].sort((a, b) => a.sort_order - b.sort_order);
|
||||
result[key].sort(cmp);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -304,7 +316,9 @@
|
||||
// active) — otherwise a drag would persist
|
||||
// the relevance-ranked subset order as the
|
||||
// stored `sort_order`. TASK-1367 / Codex R5.
|
||||
dragDisabled: !canEdit || preserveOrder
|
||||
// Also disable under any non-manual page sort
|
||||
// (TASK-1670) — the group is comparator-ordered.
|
||||
dragDisabled: !canEdit || preserveOrder || sortMode !== 'manual'
|
||||
}}
|
||||
onconsider={(e) => handleConsider(groupName, e)}
|
||||
onfinalize={(e) => handleFinalize(groupName, e)}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { localSearch, parseSearchQuery } from '$lib/stores/localSearch.svelte';
|
||||
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
|
||||
import { confirmOpenChildrenOrThrow, isOpenChildrenError } from '$lib/items/openChildrenError';
|
||||
import { SORT_OPTIONS, priorityField, type SortMode } from '$lib/collections/itemSort';
|
||||
|
||||
type ViewMode = 'list' | 'board' | 'table';
|
||||
|
||||
@@ -38,6 +39,9 @@
|
||||
let metaLoading = $state(true);
|
||||
let collection = $state<Collection | null>(null);
|
||||
let viewMode = $state<ViewMode>('list');
|
||||
// Page-wide within-group sort (TASK-1670 / IDEA-1648). 'manual' is the
|
||||
// stored sort_order (drag order). Persisted per collection.
|
||||
let sortMode = $state<SortMode>('manual');
|
||||
let activeFilters = $state<Record<string, string>>({});
|
||||
// Multi-select tag filter (OR semantics). Tags live on the top-level
|
||||
// `tags` column, not in `fields` JSON, so they're tracked separately
|
||||
@@ -197,6 +201,39 @@
|
||||
return defaultMode;
|
||||
}
|
||||
|
||||
// Persist the page-wide sort per collection (mirrors saveViewMode).
|
||||
function saveSortMode(mode: SortMode) {
|
||||
sortMode = mode;
|
||||
if (collSlug) {
|
||||
try { localStorage.setItem(`pad-sort-${collSlug}`, mode); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSavedSortMode(coll: string): SortMode {
|
||||
try {
|
||||
const saved = localStorage.getItem(`pad-sort-${coll}`);
|
||||
if (SORT_OPTIONS.some((o) => o.value === saved)) return saved as SortMode;
|
||||
} catch {}
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
// Sort options available for this collection: hide "Priority" when the
|
||||
// collection has no `priority` select field (it would be a no-op).
|
||||
let sortOptions = $derived(
|
||||
collection && priorityField(collection)
|
||||
? SORT_OPTIONS
|
||||
: SORT_OPTIONS.filter((o) => o.value !== 'priority')
|
||||
);
|
||||
|
||||
// Fall back to 'manual' if the active sort isn't valid for this
|
||||
// collection (e.g. a 'priority' choice persisted for a collection
|
||||
// that has no priority field after switching collections).
|
||||
$effect(() => {
|
||||
if (!sortOptions.some((o) => o.value === sortMode)) {
|
||||
sortMode = 'manual';
|
||||
}
|
||||
});
|
||||
|
||||
// Sync filters to URL query params (shareable)
|
||||
function updateUrlFilters() {
|
||||
if (!collSlug || !wsSlug) return;
|
||||
@@ -501,6 +538,7 @@
|
||||
const defaultMode = (['board', 'list', 'table'].includes(settings.default_view))
|
||||
? settings.default_view as ViewMode : 'list';
|
||||
viewMode = loadSavedViewMode(coll, defaultMode);
|
||||
sortMode = loadSavedSortMode(coll);
|
||||
|
||||
// Override with URL params if present
|
||||
loadUrlFilters();
|
||||
@@ -1542,6 +1580,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if viewMode !== 'table'}
|
||||
<label class="sort-control" title="Sort items">
|
||||
<span class="sort-label">Sort</span>
|
||||
<select
|
||||
class="sort-select"
|
||||
value={sortMode}
|
||||
onchange={(e) => saveSortMode(e.currentTarget.value as SortMode)}
|
||||
aria-label="Sort items"
|
||||
>
|
||||
{#each sortOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="filter-toggle-btn"
|
||||
class:has-filters={hasActiveFilters}
|
||||
@@ -1791,6 +1845,7 @@
|
||||
{progressLabel}
|
||||
canEdit={canEditThisCollection}
|
||||
preserveOrder={searchQuery.trim() !== ''}
|
||||
{sortMode}
|
||||
/>
|
||||
{:else if viewMode === 'table'}
|
||||
<TableView
|
||||
@@ -1819,6 +1874,7 @@
|
||||
{progressLabel}
|
||||
canEdit={canEditThisCollection}
|
||||
preserveOrder={searchQuery.trim() !== ''}
|
||||
{sortMode}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -2115,6 +2171,38 @@
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* Page-wide sort control (TASK-1670) — sits next to the view toggle. */
|
||||
.sort-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.82em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.sort-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
appearance: auto;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 6px;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.archive-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user