Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/sim-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ const { sort, dir, activeSort, onSort, onClear } = useUrlSort(thingsSortParams,
Two modes, chosen by whether you pass a default:

- **Defaulted (the common case)** — pass the list's existing default sort; it must match exactly. A clean URL means the default ordering; explicitly selecting the default collapses back to a clean URL (`clearOnDefault`), and "clear sort" writes the defaults back. `useUrlSort` derives `activeSort: null` for the default state.
- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. files: with no sort, files order by updated/desc but folders by name/asc). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s).
- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. document chunks: with no sort the query omits `sortBy` entirely and the server's own order applies). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s).

Sort params live alongside — not inside — the feature's grouped filter parser map (one definition per param; `useUrlSort` owns its own `useQueryStates`, and nuqs keeps hooks on the same keys in sync). Both params carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). Free-form user-defined columns (e.g. `tables/[tableId]`) can't use `parseAsStringLiteral` and stay hand-rolled with `parseAsString` — reuse the shared `SORT_DIRECTIONS` there.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export {
renderMoveOption,
renderMoveOptions,
} from './move-options'
export type { SortableResource } from './resource-sort'
export { sortResources } from './resource-sort'
export { folderNavParsers, folderNavUrlKeys } from './search-params'
export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation'
export { useFolderNavigation } from './use-folder-navigation'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
type SortableResource,
sortResources,
} from '@/app/workspace/[workspaceId]/components/folders/resource-sort'

type Kind = 'folder' | 'item'

function entry(
name: string,
kind: Kind,
key: string | number | null,
pinned = false
): SortableResource<{ name: string; kind: Kind }> {
return { item: { name, kind }, pinned, name, key }
}

const names = (entries: SortableResource<{ name: string; kind: Kind }>[]) =>
entries.map((e) => e.item.name)

describe('sortResources', () => {
it('interleaves folders and items on the sort key instead of hoisting folders', () => {
const sorted = sortResources(
[
entry('b-folder', 'folder', 'b-folder'),
entry('a-item', 'item', 'a-item'),
entry('c-item', 'item', 'c-item'),
],
'asc'
)

expect(names(sorted)).toEqual(['a-item', 'b-folder', 'c-item'])
})

it('floats a pinned item above every unpinned folder', () => {
const sorted = sortResources(
[
entry('a-folder', 'folder', 'a-folder'),
entry('b-folder', 'folder', 'b-folder'),
entry('z-item', 'item', 'z-item', true),
],
'asc'
)

expect(names(sorted)).toEqual(['z-item', 'a-folder', 'b-folder'])
})

it('keeps pinned rows on top when the direction flips', () => {
const sorted = sortResources(
[
entry('a-folder', 'folder', 3),
entry('b-item', 'item', 2),
entry('c-item', 'item', 1, true),
],
'desc'
)

expect(names(sorted)).toEqual(['c-item', 'a-folder', 'b-item'])
})

it('orders pinned rows among themselves by the active key', () => {
const sorted = sortResources(
[
entry('a-item', 'item', 1, true),
entry('b-folder', 'folder', 3, true),
entry('c-item', 'item', 2, true),
],
'desc'
)

expect(names(sorted)).toEqual(['b-folder', 'c-item', 'a-item'])
})

it('sorts rows with no value for the column last in both directions', () => {
const rows = [
entry('folder-a', 'folder', null),
entry('item-big', 'item', 10),
entry('item-small', 'item', 1),
]

expect(names(sortResources([...rows], 'asc'))).toEqual(['item-small', 'item-big', 'folder-a'])
expect(names(sortResources([...rows], 'desc'))).toEqual(['item-big', 'item-small', 'folder-a'])
})

it('still floats a pinned row that has no value for the column', () => {
const sorted = sortResources(
[entry('item-a', 'item', 5), entry('folder-z', 'folder', null, true)],
'asc'
)

expect(names(sorted)).toEqual(['folder-z', 'item-a'])
})

it('sorts a row whose cell renders empty last, not first', () => {
// An owner id that resolves to no workspace member renders an empty cell, so its key is
// `null` — passing `''` instead would float those rows to the top of an ascending sort.
const rows = [entry('unknown-owner', 'item', null), entry('ada', 'item', 'Ada')]

expect(names(sortResources([...rows], 'asc'))).toEqual(['ada', 'unknown-owner'])
expect(names(sortResources([...rows], 'desc'))).toEqual(['ada', 'unknown-owner'])
})

it('breaks ties by name ascending regardless of direction', () => {
const rows = [
entry('charlie', 'item', 1),
entry('alpha', 'folder', 1),
entry('bravo', 'item', 1),
]

expect(names(sortResources([...rows], 'asc'))).toEqual(['alpha', 'bravo', 'charlie'])
expect(names(sortResources([...rows], 'desc'))).toEqual(['alpha', 'bravo', 'charlie'])
})

it('breaks ties by name among rows that all lack a value', () => {
const sorted = sortResources(
[entry('zeta', 'folder', null), entry('alpha', 'folder', null)],
'desc'
)

expect(names(sorted)).toEqual(['alpha', 'zeta'])
})

it('compares string keys case-insensitively via localeCompare', () => {
const sorted = sortResources(
[entry('Beta', 'item', 'Beta'), entry('alpha', 'folder', 'alpha')],
'asc'
)

expect(names(sorted)).toEqual(['alpha', 'Beta'])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { SortDirection } from '@/lib/url-state'

/**
* One row of a foldered list, decorated with everything the comparator needs so the sort
* itself stays O(N log N) on precomputed values rather than re-deriving keys per comparison.
*/
export interface SortableResource<T> {
/** The payload handed back in sorted order. */
item: T
/** Pinned rows float to the top of every column and direction. */
pinned: boolean
/** Display name — the final, direction-independent tiebreaker. */
name: string
/**
* Value for the active sort column, or `null` when the column does not apply to this row
* (a folder has no row count, token count, or connector list). Null keys sort last in both
* directions, the same "nulls last" rule the log list applies server-side.
*/
key: string | number | null
}

/**
* Orders folders and the resources they contain as ONE list.
*
* Folders are not hoisted above their siblings: a folder outranking every file meant a pinned
* file could never reach the top of the list, since pinning only reordered within each
* partition. Precedence is pinned → sort key → name, so pinning is the only thing that jumps
* a row, and it does so regardless of which column is sorted or which way.
*
* Neither the pinned bit nor the name tiebreaker is inverted by `desc` — pinning is a
* user-declared priority rather than another sort key, and a stable A→Z tiebreak keeps rows
* that tie on the active column (equal timestamps, a whole column of `null` folder keys) in
* one predictable order instead of the arbitrary one their source arrays happened to have.
*/
export function sortResources<T>(
entries: SortableResource<T>[],
direction: SortDirection
): SortableResource<T>[] {
return entries.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1

if (a.key === null || b.key === null) {
if (a.key !== b.key) return a.key === null ? 1 : -1
} else {
const cmp =
typeof a.key === 'number' && typeof b.key === 'number'
? a.key - b.key
: String(a.key).localeCompare(String(b.key))
if (cmp !== 0) return direction === 'asc' ? cmp : -cmp
}

return a.name.localeCompare(b.name)
})
}
Loading
Loading